Skip to content
Open
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
127 changes: 79 additions & 48 deletions Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ func pubkyAuthDisplayPublicKey(_ publicKey: String?) -> String {
return "\(rawKey.prefix(4))...\(rawKey.suffix(4))"
}

/// Request data is shown literally: accent tags inside it must not be read as markup by the text components.
func pubkyAuthLiteralText(_ value: String) -> String {
var text = value
while true {
let stripped = text.replacingOccurrences(of: "<accent>", with: "").replacingOccurrences(of: "</accent>", with: "")
if stripped == text { return text }
text = stripped
}
}

struct PubkyAuthApprovalConfig {
let request: PubkyAuthRequest
}
Expand Down Expand Up @@ -143,7 +153,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 +213,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 +223,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 +266,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 +280,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 +309,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 +319,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")
: t("pubky_auth__description_named", variables: ["clientId": requesterText, "service": serviceText]),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Consent sentence rewrites requester ids that contain placeholders

Trigger: a Paykit auth URL whose client id or service path contains another consent placeholder, such as cid={service} with caps=/pub/paykit/:rw, or cid=bank.com with caps=/pub/{clientId}/:rw. ClientId::new in pubky-common 0.15.0 (paykit-rs 2ea4b2b) accepts any non-empty string up to 253 characters, and storage paths allow { in a segment.

Mechanism: descriptionText and successDescriptionText pass those values through t(), which replaces {clientId}, {service}, and {pubky} with replacingOccurrences and then keeps scanning the result for the remaining keys. Dictionary order is not fixed. pubkyAuthLiteralText only removes <accent> tags, so a value such as {service} is inserted and can be replaced by the service name. The same pass can turn a service name of {clientId} into the client id, and on the success sentence a client id containing {pubky} can include the truncated pubky.

Consequence: this path would name a different requester or service than the grant. The lead sentence is now the only client-id display, so a cid of {service} can read as paykit while the authorized client id remains {service}. The permission row still shows the raw path, but not the client id.

Expected behavior: show the requester id and service name literally, including {service}, {clientId}, and {pubky}. Substitute each template placeholder from the original string only, without scanning inserted values. Android already does that with positional %s arguments.

Evidence basis: source analysis of t() and the two call sites at 12e44e9, plus the same replacement sequence checked outside the app. The iOS UI was not run. Run Tests does not cover this input.

accentColor: .textPrimary,
accentFont: Fonts.bold
)
Expand Down Expand Up @@ -341,9 +358,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")
: t(
"pubky_auth__success_named",
variables: ["pubky": truncatedPublicKey, "clientId": requesterText, "service": serviceText]
),
accentColor: .textPrimary,
accentFont: Fonts.bold
)
Expand All @@ -357,15 +379,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 +398,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
6 changes: 6 additions & 0 deletions BitkitTests/PubkyAuthApprovalSheetTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -621,4 +621,10 @@ private func XCTAssertThrowsErrorAsync(
_ = try await expression()
XCTFail("Expected expression to throw", file: file, line: line)
} catch {}

func testRequestTextLosesAccentMarkupEvenWhenTagsAreNested() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] Accent-tag test is nested where XCTest will not run it

Trigger: PubkyAuthApprovalSheetTests runs in CI or locally.

Mechanism: testRequestTextLosesAccentMarkupEvenWhenTagsAreNested is declared inside the file-private function XCTAssertThrowsErrorAsync, after PubkyAuthApprovalSheetTests has already closed. Nothing calls that nested function. XCTest only runs methods on the test class, so the three XCTAssertEqual checks are never executed. The function still typechecks, which is why the suite can pass.

Consequence: a regression in pubkyAuthLiteralText would not fail CI. The PR presents this test as coverage for nested <accent> tags.

Expected behavior: declare the test as a method on PubkyAuthApprovalSheetTests so the nested-tag cases run with the class.

Evidence basis: source analysis of BitkitTests/PubkyAuthApprovalSheetTests.swift at 12e44e9. The suite was not rerun here.

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")
}
}
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