diff --git a/swift/Sources/Features/Auth/LoginRootView.swift b/swift/Sources/Features/Auth/LoginRootView.swift index 4393062..f7ff904 100644 --- a/swift/Sources/Features/Auth/LoginRootView.swift +++ b/swift/Sources/Features/Auth/LoginRootView.swift @@ -12,64 +12,34 @@ struct LoginRootView: View { MacPairingView(model: model) #else NavigationStack { - ZStack(alignment: .top) { - LiveQRScannerView { payload in + PairingWelcomeView( + model: model, + onScanRequested: { + model.isScannerPresented = true + }, + onNFCRequested: { + isNFCSheetPresented = true + } + ) + .fullScreenCover(isPresented: $model.isScannerPresented) { + QRScannerSheet( + title: "Scan pairing QR", + description: "Open pairing in your idp.global web session, then scan the QR shown there to link this iPhone.", + navigationTitle: "Link Passport" + ) { payload in model.manualPairingPayload = payload Task { await model.signIn(with: payload, transport: .qr) } } - .ignoresSafeArea() - - VStack(spacing: 0) { - IdPGlassCapsule { - VStack(alignment: .leading, spacing: 6) { - Text("Scan a pairing code") - .font(.headline) - - Text("Turn this iPhone into your idp.global passport with QR or NFC.") - .font(.subheadline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.horizontal, 16) - .padding(.top, 12) - - Spacer() - - Button { - isNFCSheetPresented = true - } label: { - IdPGlassCapsule { - HStack(spacing: 10) { - Image(systemName: "wave.3.right") - .foregroundStyle(IdP.tint) - Text("Hold near NFC tag") - .font(.headline) - .foregroundStyle(.primary) - } - } - } - .buttonStyle(.plain) - .padding(.horizontal, 16) - .padding(.bottom, 24) - } - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Use demo payload") { - Task { - await model.signInWithSuggestedPayload() - } - } - .font(.footnote) - .disabled(model.isAuthenticating || model.suggestedPairingPayload.isEmpty) - } } } .sheet(isPresented: $isNFCSheetPresented) { - NFCSheet(actionTitle: "Approve") { request in + NFCSheet( + title: "Hold near pairing tag", + message: "Use a supported idp.global NFC tag to link this iPhone and attach a signed location proof.", + actionTitle: "Link device" + ) { request in await model.signIn(with: request) } } @@ -77,6 +47,163 @@ struct LoginRootView: View { } } +#if !os(macOS) +private struct PairingWelcomeView: View { + @ObservedObject var model: AppViewModel + let onScanRequested: () -> Void + let onNFCRequested: () -> Void + + var body: some View { + AppScrollScreen(compactLayout: true, bottomPadding: 150) { + AppPanel(compactLayout: true, radius: 30) { + ShadcnBadge( + title: "Passport setup", + tone: .accent, + leading: Image(systemName: "shield.lefthalf.filled") + ) + + HStack(alignment: .top, spacing: 16) { + ZStack { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(IdP.tint.opacity(0.14)) + .frame(width: 72, height: 72) + + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 30, weight: .semibold)) + .foregroundStyle(IdP.tint) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Link this iPhone to your idp.global account") + .font(.title2.weight(.bold)) + .fixedSize(horizontal: false, vertical: true) + + Text("Start pairing in your browser, then scan the fresh QR on the next screen. This device becomes your passport for approvals, alerts, and identity proofs.") + .font(.subheadline) + .foregroundStyle(Color.idpMutedForeground) + } + } + + Text("No camera? The scanner screen also lets you paste a pairing link manually.") + .font(.footnote) + .foregroundStyle(Color.idpMutedForeground) + } + + AppSectionCard( + title: "How pairing works", + subtitle: "The QR is created by the web session you want to trust.", + compactLayout: true + ) { + VStack(spacing: 14) { + PairingStepRow( + number: 1, + title: "Start from the web", + message: "Open idp.global in your browser and begin a new device pairing flow." + ) + + PairingStepRow( + number: 2, + title: "Scan the QR", + message: "Use the camera on the next screen to scan the pairing QR shown by that browser session." + ) + + PairingStepRow( + number: 3, + title: "Approve future sign-ins", + message: "Once linked, this iPhone can receive approval requests and security alerts." + ) + } + } + + AppSectionCard( + title: "Before you scan", + subtitle: "A few quick checks help the link complete cleanly.", + compactLayout: true + ) { + VStack(alignment: .leading, spacing: 10) { + PairingNoteRow(icon: "safari", text: "Keep the pairing page open in your browser until this phone confirms the link.") + PairingNoteRow(icon: "camera.fill", text: "Grant camera access when prompted so the QR can be read directly.") + PairingNoteRow(icon: "wave.3.right", text: "If your organization uses NFC tags, you can link with NFC instead of QR.") + } + } + } + .navigationTitle("Welcome") + .idpInlineNavigationTitle() + .safeAreaInset(edge: .bottom) { + VStack(spacing: 10) { + Button(action: onScanRequested) { + HStack(spacing: 10) { + if model.isAuthenticating { + ProgressView() + .progressViewStyle(.circular) + .tint(Color.idpPrimaryForeground) + } else { + Image(systemName: "camera.viewfinder") + } + + Text(model.isAuthenticating ? "Linking device..." : "Scan pairing QR") + } + } + .buttonStyle(PrimaryActionStyle()) + .disabled(model.isAuthenticating) + + Button(action: onNFCRequested) { + Label("Use NFC tag instead", systemImage: "wave.3.right") + } + .buttonStyle(SecondaryActionStyle()) + .disabled(model.isAuthenticating) + } + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 14) + .background(.regularMaterial) + } + } +} + +private struct PairingStepRow: View { + let number: Int + let title: String + let message: String + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Text("\(number)") + .font(.subheadline.weight(.bold)) + .frame(width: 28, height: 28) + .foregroundStyle(IdP.tint) + .background(IdP.tint.opacity(0.12), in: Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.semibold)) + Text(message) + .font(.footnote) + .foregroundStyle(Color.idpMutedForeground) + } + } + } +} + +private struct PairingNoteRow: View { + let icon: String + let text: String + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon) + .foregroundStyle(IdP.tint) + .frame(width: 18, height: 18) + + Text(text) + .font(.footnote) + .foregroundStyle(Color.idpMutedForeground) + .fixedSize(horizontal: false, vertical: true) + } + } +} +#endif + #if os(macOS) private struct MacPairingView: View { @ObservedObject var model: AppViewModel diff --git a/swift/Sources/Features/Auth/QRScannerView.swift b/swift/Sources/Features/Auth/QRScannerView.swift index 5135212..3a3c806 100644 --- a/swift/Sources/Features/Auth/QRScannerView.swift +++ b/swift/Sources/Features/Auth/QRScannerView.swift @@ -9,7 +9,6 @@ import AppKit #endif struct QRScannerSheet: View { - let seededPayload: String let title: String let description: String let navigationTitleText: String @@ -19,13 +18,11 @@ struct QRScannerSheet: View { @State private var manualFallback = "" init( - seededPayload: String, title: String = "Scan QR", description: String = "Use the camera to scan an idp.global QR challenge.", navigationTitle: String = "Scan QR", onCodeScanned: @escaping (String) -> Void ) { - self.seededPayload = seededPayload self.title = title self.description = description self.navigationTitleText = navigationTitle @@ -55,33 +52,18 @@ struct QRScannerSheet: View { Spacer() - VStack(alignment: .leading, spacing: 12) { - Text("Manual fallback") - .font(.headline) - - TextEditor(text: $manualFallback) - .font(.footnote.monospaced()) - .scrollContentBackground(.hidden) - .frame(minHeight: 110) - .padding(10) - .background(Color.idpTertiaryFill, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) - - VStack(spacing: 10) { - Button("Use payload") { - let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines) - onCodeScanned(chosen.isEmpty ? seededPayload : chosen) - dismiss() - } - .buttonStyle(PrimaryActionStyle()) - - Button("Use demo payload") { - manualFallback = seededPayload - } - .buttonStyle(SecondaryActionStyle()) + ManualFallbackCard( + manualFallback: $manualFallback, + onUsePayload: { + let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines) + guard !chosen.isEmpty else { return } + onCodeScanned(chosen) + dismiss() + }, + onPasteFromClipboard: { + manualFallback = ClipboardPayloadReader.readString() } - } - .padding(18) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) + ) } .padding(16) } @@ -94,9 +76,6 @@ struct QRScannerSheet: View { } } } - .onAppear { - manualFallback = seededPayload - } } } } @@ -119,7 +98,7 @@ struct LiveQRScannerView: View { @State private var didDetectCode = false var body: some View { - ZStack(alignment: .bottomLeading) { + ZStack { Group { if scanner.isPreviewAvailable { ScannerPreview(session: scanner.captureSession) @@ -141,21 +120,46 @@ struct LiveQRScannerView: View { } } - Color.black.opacity(0.18) + LinearGradient( + colors: [Color.black.opacity(0.64), Color.clear, Color.black.opacity(0.78)], + startPoint: .top, + endPoint: .bottom + ) .ignoresSafeArea() ScanFrameOverlay(detected: didDetectCode) .padding(40) - VStack(alignment: .leading, spacing: 6) { - Text("Point the camera at the pairing QR") - .font(.headline.weight(.semibold)) - .foregroundStyle(.white) - Text(scanner.statusMessage) - .font(.subheadline) - .foregroundStyle(.white.opacity(0.84)) + VStack { + HStack { + ScannerStatusBadge(title: scanner.isPreviewAvailable ? "Camera ready" : "Manual fallback") + Spacer() + } + .padding(.horizontal, 20) + .padding(.top, 18) + + Spacer() + + VStack(alignment: .leading, spacing: 8) { + Text("Align the pairing QR inside the frame") + .font(.headline.weight(.semibold)) + .foregroundStyle(.white) + + Text(scanner.statusMessage) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.84)) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .background(Color.black.opacity(0.34), in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + .padding(.horizontal, 18) + .padding(.bottom, 18) } - .padding(22) } .task { scanner.onCodeScanned = { payload in @@ -185,6 +189,17 @@ private struct ScanFrameOverlay: View { let inset = detected ? 18.0 : 0 ZStack { + RoundedRectangle(cornerRadius: 30, style: .continuous) + .fill(Color.black.opacity(0.18)) + .frame(width: size - inset, height: size - inset) + + RoundedRectangle(cornerRadius: 30, style: .continuous) + .stroke( + Color.white.opacity(detected ? 0.95 : 0.75), + style: StrokeStyle(lineWidth: detected ? 3 : 2, lineCap: .round, lineJoin: .round) + ) + .frame(width: size - inset, height: size - inset) + CornerTick(rotation: .degrees(0)) .frame(width: size, height: size) CornerTick(rotation: .degrees(90)) @@ -202,6 +217,110 @@ private struct ScanFrameOverlay: View { } } +private struct ScannerStatusBadge: View { + let title: String + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(IdP.tint) + .frame(width: 8, height: 8) + + Text(title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.white) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.black.opacity(0.26), in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(Color.white.opacity(0.10), lineWidth: 1) + ) + } +} + +private struct ManualFallbackCard: View { + @Binding var manualFallback: String + let onUsePayload: () -> Void + let onPasteFromClipboard: () -> Void + + private var hasManualPayload: Bool { + !manualFallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "doc.text.viewfinder") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(IdP.tint) + .frame(width: 34, height: 34) + .background(IdP.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + + VStack(alignment: .leading, spacing: 4) { + Text("Paste pairing link") + .font(.headline) + + Text("If scanning is unavailable, paste the idp.global pairing link from your browser or clipboard.") + .font(.footnote) + .foregroundStyle(Color.idpMutedForeground) + .fixedSize(horizontal: false, vertical: true) + } + } + + ZStack(alignment: .topLeading) { + TextEditor(text: $manualFallback) + .font(.footnote.monospaced()) + .scrollContentBackground(.hidden) + .frame(minHeight: 110) + .padding(10) + .background(Color.idpTertiaryFill, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) + + if manualFallback.isEmpty { + Text("idp.global://pair?token=...") + .font(.footnote.monospaced()) + .foregroundStyle(Color.idpMutedForeground) + .padding(.horizontal, 16) + .padding(.vertical, 18) + .allowsHitTesting(false) + } + } + + VStack(spacing: 10) { + Button("Use pasted payload", action: onUsePayload) + .buttonStyle(PrimaryActionStyle()) + .disabled(!hasManualPayload) + + Button { + onPasteFromClipboard() + } label: { + Label("Paste from clipboard", systemImage: "doc.on.clipboard") + } + .buttonStyle(SecondaryActionStyle()) + } + } + .padding(18) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) + .stroke(Color.white.opacity(0.10), lineWidth: 1) + ) + } +} + +private enum ClipboardPayloadReader { + static func readString() -> String { + #if os(iOS) + UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + #elseif os(macOS) + NSPasteboard.general.string(forType: .string)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + #else + "" + #endif + } +} + private struct CornerTick: View { let rotation: Angle @@ -233,7 +352,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet #if os(iOS) && targetEnvironment(simulator) await MainActor.run { isPreviewAvailable = false - statusMessage = "The iOS simulator has no live camera feed. Use the demo payload below." + statusMessage = "The iOS simulator has no live camera feed. Paste a pairing link below instead." } #endif