Refocus app around identity proof flows

This commit is contained in:
2026-04-18 01:05:22 +02:00
parent d195037eb6
commit ea6b45388f
45 changed files with 2784 additions and 3159 deletions

View File

@@ -1,29 +1,26 @@
import SwiftUI
private let loginAccent = Color(red: 0.12, green: 0.40, blue: 0.31)
private let loginGold = Color(red: 0.90, green: 0.79, blue: 0.60)
private let loginAccent = AppTheme.accent
struct LoginRootView: View {
@ObservedObject var model: AppViewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
ScrollView {
VStack(spacing: compactLayout ? 18 : 24) {
LoginHeroPanel(model: model, compactLayout: compactLayout)
PairingConsoleCard(model: model, compactLayout: compactLayout)
TrustFootprintCard(model: model, compactLayout: compactLayout)
}
.frame(maxWidth: 1040)
.padding(compactLayout ? 18 : 28)
AppScrollScreen(compactLayout: compactLayout) {
LoginHeroPanel(model: model, compactLayout: compactLayout)
PairingConsoleCard(model: model, compactLayout: compactLayout)
}
.sheet(isPresented: $model.isScannerPresented) {
QRScannerSheet(
seededPayload: model.suggestedQRCodePayload,
seededPayload: model.suggestedPairingPayload,
title: "Scan linking QR",
description: "Use the camera to scan the QR code from the web flow that activates this device as your passport.",
navigationTitle: "Scan Linking QR",
onCodeScanned: { payload in
model.manualQRCodePayload = payload
model.manualPairingPayload = payload
Task {
await model.signIn(with: payload)
await model.signIn(with: payload, transport: .qr)
}
}
)
@@ -44,51 +41,49 @@ private struct LoginHeroPanel: View {
let compactLayout: Bool
var body: some View {
ZStack(alignment: .bottomLeading) {
RoundedRectangle(cornerRadius: 36, style: .continuous)
.fill(
LinearGradient(
colors: [
Color(red: 0.13, green: 0.22, blue: 0.19),
Color(red: 0.20, green: 0.41, blue: 0.33),
loginGold
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
AppBadge(title: "Secure passport setup", tone: loginAccent)
VStack(alignment: .leading, spacing: compactLayout ? 16 : 18) {
Text("Bind this device to your idp.global account")
.font(.system(size: compactLayout ? 32 : 44, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Turn this device into a passport for your idp.global identity")
.font(.system(size: compactLayout ? 28 : 36, weight: .bold, design: .rounded))
.lineLimit(3)
Text("Scan the pairing QR from your account to turn this device into your approval and notification app.")
.font(compactLayout ? .body : .title3)
.foregroundStyle(.white.opacity(0.88))
Text("Scan a linking QR code or paste a payload to activate this device as your passport for identity proofs and security alerts.")
.font(.subheadline)
.foregroundStyle(.secondary)
if compactLayout {
VStack(alignment: .leading, spacing: 10) {
HeroTag(title: "Account binding")
HeroTag(title: "QR pairing")
HeroTag(title: "iPhone, iPad, Mac")
}
} else {
HStack(spacing: 12) {
HeroTag(title: "Account binding")
HeroTag(title: "QR pairing")
HeroTag(title: "iPhone, iPad, Mac")
}
}
Divider()
if model.isBootstrapping {
ProgressView("Preparing preview pairing payload…")
.tint(.white)
}
VStack(alignment: .leading, spacing: 14) {
LoginFeatureRow(icon: "qrcode.viewfinder", title: "Scan a QR code from the web flow")
LoginFeatureRow(icon: "doc.text.viewfinder", title: "Paste a payload when you already have one")
LoginFeatureRow(icon: "iphone.gen3", title: "Handle identity checks and alerts here")
}
if model.isBootstrapping {
ProgressView("Preparing preview passport...")
.tint(loginAccent)
}
.padding(compactLayout ? 22 : 32)
}
.frame(minHeight: compactLayout ? 280 : 320)
}
}
private struct LoginFeatureRow: View {
let icon: String
let title: String
var body: some View {
HStack(alignment: .center, spacing: 12) {
Image(systemName: icon)
.font(.subheadline.weight(.semibold))
.foregroundStyle(loginAccent)
.frame(width: 28, height: 28)
Text(title)
.font(.headline)
Spacer(minLength: 0)
}
}
}
@@ -97,46 +92,41 @@ private struct PairingConsoleCard: View {
let compactLayout: Bool
var body: some View {
LoginCard(title: "Bind your account", subtitle: "Scan the QR code from your idp.global account or use the preview payload while backend wiring is still in progress.") {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Open your account pairing screen, then scan the QR code here.")
.font(.headline)
Text("If you are testing the preview build without the live backend yet, the seeded payload below will still bind the mock session.")
AppSectionCard(title: "Set up passport", compactLayout: compactLayout) {
VStack(alignment: .leading, spacing: 8) {
Text("Link payload")
.font(.subheadline.weight(.semibold))
AppTextEditorField(
text: $model.manualPairingPayload,
minHeight: compactLayout ? 132 : 150
)
}
if model.isAuthenticating {
HStack(spacing: 10) {
ProgressView()
Text("Activating this passport...")
.foregroundStyle(.secondary)
}
}
TextEditor(text: $model.manualQRCodePayload)
.font(.body.monospaced())
.scrollContentBackground(.hidden)
.padding(16)
.frame(minHeight: compactLayout ? 130 : 150)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
Text("NFC, QR, and OTP proof methods become available after this passport is active.")
.font(.footnote)
.foregroundStyle(.secondary)
if model.isAuthenticating {
HStack(spacing: 10) {
ProgressView()
Text("Binding this device to your account…")
.foregroundStyle(.secondary)
}
if compactLayout {
VStack(spacing: 12) {
primaryButtons
secondaryButtons
}
Group {
if compactLayout {
VStack(spacing: 12) {
primaryButtons
secondaryButtons
}
} else {
VStack(spacing: 12) {
HStack(spacing: 12) {
primaryButtons
}
HStack(spacing: 12) {
secondaryButtons
}
}
} else {
VStack(spacing: 12) {
HStack(spacing: 12) {
primaryButtons
}
secondaryButtons
}
}
}
@@ -147,154 +137,57 @@ private struct PairingConsoleCard: View {
Button {
model.isScannerPresented = true
} label: {
Label("Bind With QR Code", systemImage: "qrcode.viewfinder")
Label("Scan QR", systemImage: "qrcode.viewfinder")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
Task {
await model.signInWithManualCode()
}
} label: {
if model.isAuthenticating {
ProgressView()
} else {
Label("Bind With Payload", systemImage: "arrow.right.circle.fill")
}
}
.buttonStyle(.bordered)
.disabled(model.isAuthenticating)
.controlSize(.large)
}
@ViewBuilder
private var secondaryButtons: some View {
if compactLayout {
VStack(spacing: 12) {
usePayloadButton
previewPayloadButton
}
} else {
HStack(spacing: 12) {
usePayloadButton
previewPayloadButton
}
}
}
private var usePayloadButton: some View {
Button {
Task {
await model.signInWithSuggestedCode()
await model.signInWithManualPayload()
}
} label: {
Label("Use Preview QR", systemImage: "wand.and.stars")
if model.isAuthenticating {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Label("Link with payload", systemImage: "arrow.right.circle")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.bordered)
Text("This preview keeps the account-binding flow realistic while the live API is still being wired in.")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .trailing)
.controlSize(.large)
.disabled(model.isAuthenticating)
}
}
private struct TrustFootprintCard: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
LoginCard(title: "About this build", subtitle: "Keep the first-run screen simple, but still explain the trust context and preview status clearly.") {
VStack(alignment: .leading, spacing: 16) {
if compactLayout {
VStack(spacing: 12) {
trustFacts
}
} else {
HStack(alignment: .top, spacing: 12) {
trustFacts
}
}
VStack(alignment: .leading, spacing: 8) {
Text("Preview Pairing Payload")
.font(.headline)
Text(model.suggestedQRCodePayload.isEmpty ? "Preparing preview payload…" : model.suggestedQRCodePayload)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
}
private var previewPayloadButton: some View {
Button {
Task {
await model.signInWithSuggestedPayload()
}
} label: {
Label("Use preview passport", systemImage: "wand.and.stars")
.frame(maxWidth: .infinity)
}
}
@ViewBuilder
private var trustFacts: some View {
TrustFactCard(
icon: "person.badge.key.fill",
title: "Account Binding",
message: "This device binds to your idp.global account and becomes your place for approvals and alerts."
)
TrustFactCard(
icon: "person.2.badge.gearshape.fill",
title: "Built by foss.global",
message: "foss.global is the open-source collective behind idp.global and the current preview environment."
)
TrustFactCard(
icon: "bolt.badge.clock",
title: "Preview Backend",
message: "Login, requests, and notifications are mocked behind a clean service boundary until live integration is ready."
)
}
}
private struct LoginCard<Content: View>: View {
let title: String
let subtitle: String
let content: () -> Content
init(title: String, subtitle: String, @ViewBuilder content: @escaping () -> Content) {
self.title = title
self.subtitle = subtitle
self.content = content
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.title2.weight(.semibold))
Text(subtitle)
.foregroundStyle(.secondary)
}
content()
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white.opacity(0.68), in: RoundedRectangle(cornerRadius: 32, style: .continuous))
}
}
private struct HeroTag: View {
let title: String
var body: some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(.white.opacity(0.14), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
private struct TrustFactCard: View {
let icon: String
let title: String
let message: String
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(loginAccent)
Text(title)
.font(.headline)
Text(message)
.foregroundStyle(.secondary)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
.buttonStyle(.bordered)
.controlSize(.large)
}
}

View File

@@ -0,0 +1,296 @@
import SwiftUI
#if canImport(CoreLocation) && canImport(CoreNFC) && canImport(CryptoKit) && os(iOS)
import CoreLocation
import CoreNFC
import CryptoKit
@MainActor
final class NFCIdentifyReader: NSObject, ObservableObject, @preconcurrency NFCNDEFReaderSessionDelegate {
@Published private(set) var helperText: String
@Published private(set) var isScanning = false
@Published private(set) var isSupported: Bool
var onAuthenticationRequestDetected: ((PairingAuthenticationRequest) -> Void)?
var onError: ((String) -> Void)?
private let signedGPSPositionProvider = SignedGPSPositionProvider()
private var session: NFCNDEFReaderSession?
private var isPreparingLocationProof = false
override init() {
let supported = NFCNDEFReaderSession.readingAvailable
_helperText = Published(initialValue: supported ? NFCIdentifyReader.idleHelperText : NFCIdentifyReader.unavailableHelperText)
_isSupported = Published(initialValue: supported)
super.init()
}
func beginScanning() {
refreshAvailability()
guard isSupported else {
onError?(Self.unavailableErrorMessage)
return
}
guard !isScanning else { return }
isScanning = true
isPreparingLocationProof = false
helperText = Self.scanningHelperText
let session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: true)
session.alertMessage = "Hold your iPhone near the idp.global tag. A signed GPS position will be attached to this NFC identify action."
self.session = session
session.begin()
}
func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {
DispatchQueue.main.async {
self.helperText = Self.scanningHelperText
}
}
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
guard let payload = extractPayload(from: messages) else {
session.invalidate()
DispatchQueue.main.async {
self.finishScanning()
self.onError?(Self.invalidTagMessage)
}
return
}
DispatchQueue.main.async {
self.isPreparingLocationProof = true
self.helperText = Self.signingLocationHelperText
Task { @MainActor in
await self.completeAuthentication(for: payload)
}
}
}
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
let nsError = error as NSError
let ignoredCodes = [200, 204] // User canceled, first tag read.
DispatchQueue.main.async {
self.session = nil
}
guard !(nsError.domain == NFCErrorDomain && ignoredCodes.contains(nsError.code)) else {
if !isPreparingLocationProof {
DispatchQueue.main.async {
self.finishScanning()
}
}
return
}
DispatchQueue.main.async {
self.finishScanning()
self.onError?(Self.failureMessage(for: nsError))
}
}
@MainActor
private func completeAuthentication(for payload: String) async {
do {
let signedGPSPosition = try await signedGPSPositionProvider.currentSignedGPSPosition(for: payload)
let request = PairingAuthenticationRequest(
pairingPayload: payload,
transport: .nfc,
signedGPSPosition: signedGPSPosition
)
finishScanning()
onAuthenticationRequestDetected?(request)
} catch let error as AppError {
finishScanning()
onError?(error.errorDescription ?? Self.gpsSigningFailureMessage)
} catch {
finishScanning()
onError?(Self.gpsSigningFailureMessage)
}
}
private func finishScanning() {
session = nil
isPreparingLocationProof = false
isScanning = false
refreshAvailability()
}
private func refreshAvailability() {
let available = NFCNDEFReaderSession.readingAvailable
isSupported = available
if !isScanning {
helperText = available ? Self.idleHelperText : Self.unavailableHelperText
}
}
private func extractPayload(from messages: [NFCNDEFMessage]) -> String? {
for message in messages {
for record in message.records {
if let url = record.wellKnownTypeURIPayload() {
return url.absoluteString
}
let (text, _) = record.wellKnownTypeTextPayload()
if let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty {
return trimmed
}
if let fallback = String(data: record.payload, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!fallback.isEmpty {
return fallback
}
}
}
return nil
}
private static func failureMessage(for error: NSError) -> String {
if error.domain == NFCErrorDomain && error.code == 2 {
return "NFC identify is not permitted in this build. Check the NFC entitlement and privacy description."
}
return "NFC identify could not be completed on this device."
}
private static let idleHelperText = "Tap to identify with an NFC tag on supported iPhone hardware. A signed GPS position will be attached automatically."
private static let scanningHelperText = "Hold the top of your iPhone near the NFC tag until it is identified."
private static let signingLocationHelperText = "Tag detected. Capturing and signing the current GPS position for NFC identify."
private static let unavailableHelperText = "NFC identify is unavailable on this device."
private static let unavailableErrorMessage = "Tap to identify requires supported iPhone hardware with NFC enabled."
private static let invalidTagMessage = "The NFC tag did not contain a usable idp.global payload."
private static let gpsSigningFailureMessage = "The NFC tag was read, but the signed GPS position could not be attached."
}
@MainActor
private final class SignedGPSPositionProvider: NSObject, @preconcurrency CLLocationManagerDelegate {
private var manager: CLLocationManager?
private var authorizationContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
private var locationContinuation: CheckedContinuation<CLLocation, Error>?
func currentSignedGPSPosition(for pairingPayload: String) async throws -> SignedGPSPosition {
let location = try await currentLocation()
return try sign(location: location, pairingPayload: pairingPayload)
}
private func currentLocation() async throws -> CLLocation {
let manager = CLLocationManager()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
manager.distanceFilter = kCLDistanceFilterNone
self.manager = manager
switch manager.authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse:
break
case .notDetermined:
let status = await requestAuthorization(using: manager)
guard status == .authorizedAlways || status == .authorizedWhenInUse else {
throw AppError.locationPermissionDenied
}
case .denied, .restricted:
throw AppError.locationPermissionDenied
@unknown default:
throw AppError.locationUnavailable
}
return try await requestLocation(using: manager)
}
private func requestAuthorization(using manager: CLLocationManager) async -> CLAuthorizationStatus {
manager.requestWhenInUseAuthorization()
return await withCheckedContinuation { continuation in
authorizationContinuation = continuation
}
}
private func requestLocation(using manager: CLLocationManager) async throws -> CLLocation {
try await withCheckedThrowingContinuation { continuation in
locationContinuation = continuation
manager.requestLocation()
}
}
private func sign(location: CLLocation, pairingPayload: String) throws -> SignedGPSPosition {
let isFresh = abs(location.timestamp.timeIntervalSinceNow) <= 120
guard location.horizontalAccuracy >= 0,
isFresh else {
throw AppError.locationUnavailable
}
let unsignedPosition = SignedGPSPosition(
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude,
horizontalAccuracyMeters: location.horizontalAccuracy,
capturedAt: location.timestamp
)
let privateKey = P256.Signing.PrivateKey()
let signature = try privateKey.signature(for: unsignedPosition.signingPayload(for: pairingPayload))
return unsignedPosition.signed(
signatureData: signature.derRepresentation,
publicKeyData: privateKey.publicKey.x963Representation
)
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard let continuation = authorizationContinuation else { return }
let status = manager.authorizationStatus
guard status != .notDetermined else { return }
authorizationContinuation = nil
continuation.resume(returning: status)
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let continuation = locationContinuation,
let location = locations.last else {
return
}
authorizationContinuation = nil
locationContinuation = nil
self.manager = nil
continuation.resume(returning: location)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
guard let continuation = locationContinuation else { return }
authorizationContinuation = nil
locationContinuation = nil
self.manager = nil
if let locationError = error as? CLError,
locationError.code == .denied {
continuation.resume(throwing: AppError.locationPermissionDenied)
return
}
continuation.resume(throwing: AppError.locationUnavailable)
}
}
#else
@MainActor
final class NFCIdentifyReader: NSObject, ObservableObject {
@Published private(set) var helperText = "NFC identify with a signed GPS position is available on supported iPhone hardware only."
@Published private(set) var isScanning = false
@Published private(set) var isSupported = false
var onAuthenticationRequestDetected: ((PairingAuthenticationRequest) -> Void)?
var onError: ((String) -> Void)?
func beginScanning() {
onError?("Tap to identify requires supported iPhone hardware with NFC and location access enabled.")
}
}
#endif

View File

@@ -9,56 +9,58 @@ import AppKit
struct QRScannerSheet: View {
let seededPayload: String
let title: String
let description: String
let navigationTitleText: String
let onCodeScanned: (String) -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@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
self.onCodeScanned = onCodeScanned
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("Use the camera to scan the QR code shown by the web portal. If youre on a simulator or desktop without a camera, the seeded payload works as a mock fallback.")
AppScrollScreen(compactLayout: compactLayout) {
AppSectionCard(title: title, compactLayout: compactLayout) {
Text(description)
.font(.subheadline)
.foregroundStyle(.secondary)
LiveQRScannerView(onCodeScanned: onCodeScanned)
.frame(minHeight: 340)
}
VStack(alignment: .leading, spacing: 12) {
Text("Fallback Pairing Payload")
.font(.headline)
TextEditor(text: $manualFallback)
.font(.body.monospaced())
.scrollContentBackground(.hidden)
.padding(14)
.frame(minHeight: 120)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
AppSectionCard(title: "Manual fallback", compactLayout: compactLayout) {
AppTextEditorField(text: $manualFallback, minHeight: 120)
if compactLayout {
VStack(spacing: 12) {
useFallbackButton
useSeededButton
}
} else {
HStack(spacing: 12) {
Button {
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
onCodeScanned(chosen.isEmpty ? seededPayload : chosen)
dismiss()
} label: {
Label("Use Fallback Payload", systemImage: "arrow.up.forward.square")
}
.buttonStyle(.borderedProminent)
Button {
manualFallback = seededPayload
} label: {
Label("Use Seeded Mock", systemImage: "wand.and.rays")
}
.buttonStyle(.bordered)
useFallbackButton
useSeededButton
}
}
.padding(20)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
}
.padding(24)
}
.navigationTitle("Scan QR Code")
.navigationTitle(navigationTitleText)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
@@ -71,6 +73,36 @@ struct QRScannerSheet: View {
}
}
}
private var compactLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
}
private var useFallbackButton: some View {
Button {
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
onCodeScanned(chosen.isEmpty ? seededPayload : chosen)
dismiss()
} label: {
Label("Use payload", systemImage: "arrow.up.forward.square")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
private var useSeededButton: some View {
Button {
manualFallback = seededPayload
} label: {
Label("Reset sample", systemImage: "wand.and.rays")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
private struct LiveQRScannerView: View {