Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -718,26 +718,28 @@
"pubky_auth__title" = "Authorize";
"pubky_auth__description_prefix" = "A service is requesting permission to access and edit your ";
"pubky_auth__description_suffix" = " data.";
"pubky_auth__requester" = "Requester ID: {clientId}";
"pubky_auth__description_named" = "<accent>{clientId}</accent> is requesting permission to access and edit your <accent>{service}</accent> data.";
"pubky_auth__details" = "DETAILS";
"pubky_auth__before_you_continue" = "BEFORE YOU CONTINUE";
"pubky_auth__requested_permissions" = "REQUESTED PERMISSIONS";
"pubky_auth__watch_only_account_default_name" = "{service} account";
"pubky_auth__watch_only_account_fallback_name" = "Paykit server account";
"pubky_auth__watch_only_account_name_error" = "Enter an account name between 1 and 64 characters.";
"pubky_auth__watch_only_intro_approve" = "Approve";
"pubky_auth__watch_only_intro_description" = "To earn, you need to share a watch-only Bitcoin account with Paykit. It can view sales activity, but cannot spend funds.";
"pubky_auth__paykit_access_title" = "PRIVATE PAYKIT ACCESS";
"pubky_auth__paykit_access_description" = "Read and manage your private Paykit data, and send Paykit messages on your behalf. Your Pubky identity secret and wallet spending keys are not shared.";
"pubky_auth__paykit_access_description" = "This service will manage your private Paykit data and send Paykit messages for you. Your pubky secret and wallet keys stay private.";
"pubky_auth__watch_only_intro_relay" = "Your authorization will be delivered to <accent>{relay}</accent>.";
"pubky_auth__watch_only_intro_nav_title" = "Earn";
"pubky_auth__watch_only_intro_title" = "<accent>EARN BITCOIN</accent>\nFROM YOUR\nCONTENT";
"pubky_auth__watch_only_account_xpub_error" = "Bitkit could not create a valid account xpub.";
"pubky_auth__trust_warning" = "Make sure you trust the service, browser, or device before authorizing with your pubky.";
"pubky_auth__trust_warning" = "Make sure you trust the service, browser, and device before authorizing with your pubky.";
"pubky_auth__authorization_relay" = "AUTHORIZATION RELAY";
"pubky_auth__authorizing" = "Authorizing...";
"pubky_auth__success_title" = "Authorization Successful";
"pubky_auth__success_prefix" = "You authorized with pubky ";
"pubky_auth__success_middle" = " and gave the service permission to access and edit your ";
"pubky_auth__success_suffix" = " data.";
"pubky_auth__success_named" = "You authorized with pubky <accent>{pubky}</accent> and gave <accent>{clientId}</accent> permission to access and edit your <accent>{service}</accent> data.";
"pubky_auth__biometric_failed" = "Authentication Failed";
"pubky_auth__already_signed_in" = "Already signed in";
"pubky_auth__no_identity" = "Pubky Identity Required";
Expand Down
142 changes: 94 additions & 48 deletions Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,31 @@ func pubkyAuthDisplayPublicKey(_ publicKey: String?) -> String {
return "\(rawKey.prefix(4))...\(rawKey.suffix(4))"
}

/// Request data is shown literally: control and bidi characters are dropped so it cannot add lines or reorder the
/// sentence, and accent tags inside it must not be read as markup by the text components.
func pubkyAuthLiteralText(_ value: String) -> String {
let hidden: Set<Unicode.GeneralCategory> = [.control, .format, .lineSeparator, .paragraphSeparator]
var text = String(String.UnicodeScalarView(value.unicodeScalars.filter { !hidden.contains($0.properties.generalCategory) }))
while true {
let stripped = text.replacingOccurrences(of: "<accent>", with: "").replacingOccurrences(of: "</accent>", with: "")
if stripped == text { return text }
text = stripped
}
}

/// Fills each `{placeholder}` from the template only, so request data that itself contains a placeholder stays as it is.
func pubkyAuthFilledText(_ template: String, variables: [String: String]) -> String {
var result = ""
var rest = Substring(template)
while let open = rest.firstIndex(of: "{"), let close = rest[open...].firstIndex(of: "}") {
let key = String(rest[rest.index(after: open) ..< close])
result += rest[..<open]
result += variables[key] ?? String(rest[open ... close])
rest = rest[rest.index(after: close)...]
}
return result + rest
}

struct PubkyAuthApprovalConfig {
let request: PubkyAuthRequest
}
Expand Down Expand Up @@ -143,7 +168,7 @@ struct PubkyAuthApprovalSheet: View {
navTitle: t("pubky_auth__watch_only_intro_nav_title"),
title: t("pubky_auth__watch_only_intro_title"),
description: watchOnlyConsentDescription,
image: "coin-stack",
image: "coin-stack-4",
continueText: t("pubky_auth__watch_only_intro_approve"),
cancelText: t("common__cancel"),
accentColor: .blueAccent,
Expand Down Expand Up @@ -203,7 +228,7 @@ struct PubkyAuthApprovalSheet: View {
VStack(alignment: .leading, spacing: 0) {
approvalDetails

BodyMSBText(t("pubky_auth__authorizing"), textColor: .white32)
BodySSBText(t("pubky_auth__authorizing"), textColor: .white32)
.frame(maxWidth: .infinity)
.frame(height: 56)
}
Expand All @@ -213,18 +238,28 @@ struct PubkyAuthApprovalSheet: View {

private var successContent: some View {
VStack(alignment: .leading, spacing: 0) {
successDescriptionText
.padding(.bottom, 16)
GeometryReader { geometry in
ScrollView {
VStack(alignment: .leading, spacing: 0) {
successDescriptionText
.padding(.horizontal, 16)
.padding(.bottom, 16)

Spacer()
Spacer(minLength: 0)

Image("check")
.resizable()
.scaledToFit()
.frame(width: 256, height: 256)
.frame(maxWidth: .infinity)
Image("check")
.resizable()
.scaledToFit()
.frame(width: 256, height: 256)
.scaleEffect(Self.checkIllustrationScale)
.frame(maxWidth: .infinity)

Spacer()
Spacer(minLength: 16)
}
.frame(minHeight: geometry.size.height, alignment: .top)
}
.scrollIndicators(.hidden)
}

CustomButton(title: t("common__ok")) {
sheets.hideSheet()
Expand All @@ -246,21 +281,12 @@ struct PubkyAuthApprovalSheet: View {

if !config.request.permissions.isEmpty {
descriptionText
.padding(.bottom, 8)
}

if !config.request.clientID.isEmpty {
BodySText(t("pubky_auth__requester", variables: ["clientId": config.request.clientID]))
.lineLimit(1)
.truncationMode(.tail)
.padding(.bottom, 32)
} else {
Spacer().frame(height: 24)
}

if let relayOrigin = config.request.relayOrigin {
relayOriginSection(relayOrigin)
.padding(.bottom, 24)
.padding(.bottom, 32)
}

if !config.request.permissions.isEmpty {
Expand All @@ -269,11 +295,11 @@ struct PubkyAuthApprovalSheet: View {

if config.request.bitkitClaim?.includesPaykitAccess == true {
VStack(alignment: .leading, spacing: 8) {
CaptionMText(t("pubky_auth__paykit_access_title"), textColor: .white64)
BodySText(t("pubky_auth__paykit_access_description"))
CaptionMText(t("pubky_auth__details"), textColor: .white64)
BodySText(t("pubky_auth__paykit_access_description"), textColor: .textPrimary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.top, 24)
.padding(.top, 32)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("PubkyAuthPaykitAccess")
}
Expand All @@ -298,7 +324,7 @@ struct PubkyAuthApprovalSheet: View {
.accessibilityIdentifier("PubkySignupHomeserver")
} else {
profileCard
.padding(.bottom, 16)
.padding(.bottom, 24)
}
}
.frame(minHeight: geometry.size.height, alignment: .top)
Expand All @@ -308,12 +334,18 @@ struct PubkyAuthApprovalSheet: View {
}

private var serviceText: String {
config.request.serviceNames.joined(separator: " and ")
pubkyAuthLiteralText(config.request.serviceNames.joined(separator: " and "))
}

private var requesterText: String {
pubkyAuthLiteralText(config.request.clientID)
}

private var descriptionText: some View {
BodyMText(
t("pubky_auth__description_prefix") + "<accent>" + serviceText + "</accent>" + t("pubky_auth__description_suffix"),
requesterText.isEmpty
? t("pubky_auth__description_prefix") + "<accent>" + serviceText + "</accent>" + t("pubky_auth__description_suffix")
: pubkyAuthFilledText(t("pubky_auth__description_named"), variables: ["clientId": requesterText, "service": serviceText]),
accentColor: .textPrimary,
accentFont: Fonts.bold
)
Expand Down Expand Up @@ -341,9 +373,14 @@ struct PubkyAuthApprovalSheet: View {

private var successDescriptionText: some View {
BodyMText(
t("pubky_auth__success_prefix") + "<accent>" + truncatedPublicKey + "</accent>"
requesterText.isEmpty
? t("pubky_auth__success_prefix") + "<accent>" + truncatedPublicKey + "</accent>"
+ t("pubky_auth__success_middle") + "<accent>" + serviceText + "</accent>"
+ t("pubky_auth__success_suffix"),
+ t("pubky_auth__success_suffix")
: pubkyAuthFilledText(
t("pubky_auth__success_named"),
variables: ["pubky": truncatedPublicKey, "clientId": requesterText, "service": serviceText]
),
accentColor: .textPrimary,
accentFont: Fonts.bold
)
Expand All @@ -357,15 +394,13 @@ struct PubkyAuthApprovalSheet: View {
ForEach(Array(config.request.permissions.enumerated()), id: \.offset) { _, permission in
permissionRow(permission)
}

CustomDivider(color: .white10)
}
}

private func permissionRow(_ permission: PubkyAuthPermission) -> some View {
HStack(spacing: 4) {
Image(systemName: "folder")
.font(.system(size: 14))
.font(.system(size: 16))
.foregroundColor(.white)

BodySSBText(permission.displayPath)
Expand All @@ -378,42 +413,53 @@ struct PubkyAuthApprovalSheet: View {
}

private var trustWarning: some View {
BodySText(t("pubky_auth__trust_warning"))
.lineSpacing(4)
VStack(alignment: .leading, spacing: 8) {
CaptionMText(t("pubky_auth__before_you_continue"), textColor: .white64)
BodySText(t("pubky_auth__trust_warning"))
.fixedSize(horizontal: false, vertical: true)
}
}

private var profileCard: some View {
VStack(spacing: 16) {
CaptionMText(
truncatedPublicKey.localizedUppercase,
textColor: .white64
)

HStack(spacing: 16) {
if let imageUri = pubkyProfile.displayImageUri {
PubkyImage(uri: imageUri, size: 96)
PubkyImage(uri: imageUri, size: 48)
} else {
Circle()
.fill(Color.pubkyGreen)
.frame(width: 96, height: 96)
.fill(Color.gray5)
.frame(width: 48, height: 48)
.overlay {
Image("user-square")
.resizable()
.scaledToFit()
.foregroundColor(.white32)
.frame(width: 48, height: 48)
.frame(width: 24, height: 24)
}
}

HeadlineText(pubkyProfile.displayName ?? "")
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
VStack(alignment: .leading, spacing: 0) {
CaptionMText(
truncatedPublicKey.localizedUppercase,
textColor: .white64
)
.lineLimit(1)

BodyMSBText(pubkyProfile.displayName ?? "")
.lineLimit(1)
.truncationMode(.tail)
}

Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(24)
.background(Color.gray6)
.cornerRadius(16)
}

/// Figma draws the check illustration at 274pt inside its 256pt slot.
private static let checkIllustrationScale: CGFloat = 274.0 / 256.0

// MARK: - Actions

@MainActor
Expand Down
26 changes: 26 additions & 0 deletions BitkitTests/PubkyAuthApprovalSheetTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,32 @@ final class PubkyAuthApprovalSheetTests: XCTestCase {
XCTAssertEqual(manager.accounts.first?.isTrackingEnabled, false)
XCTAssertEqual(node.trackingChanges, [true, false])
}

func testRequestTextLosesAccentMarkupEvenWhenTagsAreNested() {
XCTAssertEqual(pubkyAuthLiteralText("<accent>evil</accent>.app"), "evil.app")
XCTAssertEqual(pubkyAuthLiteralText("<acc<accent>ent>evil</acc</accent>ent>.app"), "evil.app")
XCTAssertEqual(pubkyAuthLiteralText("app.paykit.server"), "app.paykit.server")
}

func testRequestTextLosesLineBreaksAndBidiControls() {
XCTAssertEqual(pubkyAuthLiteralText("bank.com\nBitkit is safe."), "bank.comBitkit is safe.")
XCTAssertEqual(pubkyAuthLiteralText("\u{202E}moc.knab\u{202C}"), "moc.knab")
XCTAssertEqual(pubkyAuthLiteralText("a\u{2028}b\u{2066}c\u{200B}d"), "abcd")
XCTAssertEqual(pubkyAuthLiteralText("<acc\u{200B}ent>evil</accent>"), "evil")
}

func testFilledTextKeepsPlaceholdersInsideRequestData() {
let template = "<accent>{clientId}</accent> wants <accent>{service}</accent> for {pubky}"
XCTAssertEqual(
pubkyAuthFilledText(template, variables: ["clientId": "{service}", "service": "{clientId}", "pubky": "{pubky}"]),
"<accent>{service}</accent> wants <accent>{clientId}</accent> for {pubky}"
)
XCTAssertEqual(
pubkyAuthFilledText(template, variables: ["clientId": "bank.com", "service": "paykit"]),
"<accent>bank.com</accent> wants <accent>paykit</accent> for {pubky}"
)
XCTAssertEqual(pubkyAuthFilledText("no placeholders { here", variables: ["here": "x"]), "no placeholders { here")
}
}

private enum ApprovalFakeError: Error {
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/880.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Pubky authorization dialogs now name the requesting service, group permissions and details under clear headings, and show a compact profile card.
Loading