Refocus app around identity proof flows
This commit is contained in:
409
Sources/App/AppComponents.swift
Normal file
409
Sources/App/AppComponents.swift
Normal file
@@ -0,0 +1,409 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppTheme {
|
||||
static let accent = Color(red: 0.12, green: 0.40, blue: 0.31)
|
||||
static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48)
|
||||
static let border = Color.black.opacity(0.08)
|
||||
static let shadow = Color.black.opacity(0.05)
|
||||
static let cardFill = Color.white.opacity(0.96)
|
||||
static let mutedFill = Color(red: 0.972, green: 0.976, blue: 0.970)
|
||||
}
|
||||
|
||||
enum AppLayout {
|
||||
static let compactHorizontalPadding: CGFloat = 16
|
||||
static let regularHorizontalPadding: CGFloat = 28
|
||||
static let compactVerticalPadding: CGFloat = 18
|
||||
static let regularVerticalPadding: CGFloat = 28
|
||||
static let compactContentWidth: CGFloat = 720
|
||||
static let regularContentWidth: CGFloat = 920
|
||||
static let cardRadius: CGFloat = 24
|
||||
static let largeCardRadius: CGFloat = 30
|
||||
static let compactSectionPadding: CGFloat = 18
|
||||
static let regularSectionPadding: CGFloat = 24
|
||||
static let compactSectionSpacing: CGFloat = 18
|
||||
static let regularSectionSpacing: CGFloat = 24
|
||||
static let compactBottomDockPadding: CGFloat = 120
|
||||
static let regularBottomPadding: CGFloat = 56
|
||||
|
||||
static func horizontalPadding(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactHorizontalPadding : regularHorizontalPadding
|
||||
}
|
||||
|
||||
static func verticalPadding(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactVerticalPadding : regularVerticalPadding
|
||||
}
|
||||
|
||||
static func contentWidth(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactContentWidth : regularContentWidth
|
||||
}
|
||||
|
||||
static func sectionPadding(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactSectionPadding : regularSectionPadding
|
||||
}
|
||||
|
||||
static func sectionSpacing(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactSectionSpacing : regularSectionSpacing
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func appSurface(radius: CGFloat = AppLayout.cardRadius, fill: Color = AppTheme.cardFill) -> some View {
|
||||
background(
|
||||
fill,
|
||||
in: RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
.shadow(color: AppTheme.shadow, radius: 12, y: 3)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppBackground: View {
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.975, green: 0.978, blue: 0.972),
|
||||
Color.white
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(Color.black.opacity(0.02))
|
||||
.frame(height: 160)
|
||||
.blur(radius: 60)
|
||||
.offset(y: -90)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
struct AppScrollScreen<Content: View>: View {
|
||||
let compactLayout: Bool
|
||||
var bottomPadding: CGFloat? = nil
|
||||
let content: () -> Content
|
||||
|
||||
init(
|
||||
compactLayout: Bool,
|
||||
bottomPadding: CGFloat? = nil,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.compactLayout = compactLayout
|
||||
self.bottomPadding = bottomPadding
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading)
|
||||
.padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout))
|
||||
.padding(.top, AppLayout.verticalPadding(for: compactLayout))
|
||||
.padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout))
|
||||
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppPanel<Content: View>: View {
|
||||
let compactLayout: Bool
|
||||
let radius: CGFloat
|
||||
let content: () -> Content
|
||||
|
||||
init(
|
||||
compactLayout: Bool,
|
||||
radius: CGFloat = AppLayout.cardRadius,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.compactLayout = compactLayout
|
||||
self.radius = radius
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
content()
|
||||
}
|
||||
.padding(AppLayout.sectionPadding(for: compactLayout))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.appSurface(radius: radius)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppBadge: View {
|
||||
let title: String
|
||||
var tone: Color = AppTheme.accent
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(tone)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(tone.opacity(0.10), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
struct AppSectionCard<Content: View>: View {
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
let compactLayout: Bool
|
||||
let content: () -> Content
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
compactLayout: Bool,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.compactLayout = compactLayout
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: compactLayout) {
|
||||
AppSectionTitle(title: title, subtitle: subtitle)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppSectionTitle: View {
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.title3.weight(.semibold))
|
||||
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppNotice: View {
|
||||
let message: String
|
||||
var tone: Color = AppTheme.accent
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.footnote.weight(.bold))
|
||||
.foregroundStyle(tone)
|
||||
Text(message)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(tone.opacity(0.08), in: Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppStatusTag: View {
|
||||
let title: String
|
||||
var tone: Color = AppTheme.accent
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(tone.opacity(0.12), in: Capsule())
|
||||
.foregroundStyle(tone)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppKeyValue: View {
|
||||
let label: String
|
||||
let value: String
|
||||
var monospaced: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label.uppercased())
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(monospaced ? .subheadline.monospaced() : .subheadline.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppMetric: View {
|
||||
let title: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title.uppercased())
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(.title3.weight(.bold))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppTextSurface: View {
|
||||
let text: String
|
||||
var monospaced: Bool = false
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
#if os(watchOS)
|
||||
Text(text)
|
||||
.font(monospaced ? .body.monospaced() : .body)
|
||||
#else
|
||||
Text(text)
|
||||
.font(monospaced ? .body.monospaced() : .body)
|
||||
.textSelection(.enabled)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct AppTextEditorField: View {
|
||||
@Binding var text: String
|
||||
var minHeight: CGFloat = 120
|
||||
var monospaced: Bool = true
|
||||
|
||||
var body: some View {
|
||||
editor
|
||||
.frame(minHeight: minHeight)
|
||||
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var editor: some View {
|
||||
#if os(watchOS)
|
||||
Text(text)
|
||||
.font(monospaced ? .body.monospaced() : .body)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
#else
|
||||
TextEditor(text: $text)
|
||||
.font(monospaced ? .body.monospaced() : .body)
|
||||
.scrollContentBackground(.hidden)
|
||||
.autocorrectionDisabled()
|
||||
.padding(14)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct AppActionRow: View {
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
let systemImage: String
|
||||
var tone: Color = AppTheme.accent
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(tone)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.footnote.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppActionTile: View {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
var tone: Color = AppTheme.accent
|
||||
var isBusy: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .center) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(tone.opacity(0.10))
|
||||
.frame(width: 38, height: 38)
|
||||
|
||||
if isBusy {
|
||||
ProgressView()
|
||||
.tint(tone)
|
||||
} else {
|
||||
Image(systemName: systemImage)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(tone)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, minHeight: 92, alignment: .topLeading)
|
||||
.appSurface(radius: 22)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AppViewModel: ObservableObject {
|
||||
@Published var suggestedQRCodePayload = ""
|
||||
@Published var manualQRCodePayload = ""
|
||||
@Published var suggestedPairingPayload = ""
|
||||
@Published var manualPairingPayload = ""
|
||||
@Published var session: AuthSession?
|
||||
@Published var profile: MemberProfile?
|
||||
@Published var requests: [ApprovalRequest] = []
|
||||
@@ -13,11 +13,11 @@ final class AppViewModel: ObservableObject {
|
||||
@Published var selectedSection: AppSection = .overview
|
||||
@Published var isBootstrapping = false
|
||||
@Published var isAuthenticating = false
|
||||
@Published var isIdentifying = false
|
||||
@Published var isRefreshing = false
|
||||
@Published var isNotificationCenterPresented = false
|
||||
@Published var activeRequestID: ApprovalRequest.ID?
|
||||
@Published var isScannerPresented = false
|
||||
@Published var bannerMessage: String?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private var hasBootstrapped = false
|
||||
@@ -84,13 +84,13 @@ final class AppViewModel: ObservableObject {
|
||||
|
||||
do {
|
||||
let bootstrap = try await service.bootstrap()
|
||||
suggestedQRCodePayload = bootstrap.suggestedQRCodePayload
|
||||
manualQRCodePayload = bootstrap.suggestedQRCodePayload
|
||||
suggestedPairingPayload = bootstrap.suggestedPairingPayload
|
||||
manualPairingPayload = bootstrap.suggestedPairingPayload
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
|
||||
if launchArguments.contains("--mock-auto-pair"),
|
||||
session == nil {
|
||||
await signIn(with: bootstrap.suggestedQRCodePayload)
|
||||
await signIn(with: bootstrap.suggestedPairingPayload, transport: .preview)
|
||||
|
||||
if let preferredLaunchSection {
|
||||
selectedSection = preferredLaunchSection
|
||||
@@ -101,32 +101,52 @@ final class AppViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func signInWithManualCode() async {
|
||||
await signIn(with: manualQRCodePayload)
|
||||
func signInWithManualPayload() async {
|
||||
await signIn(with: manualPairingPayload, transport: .manual)
|
||||
}
|
||||
|
||||
func signInWithSuggestedCode() async {
|
||||
manualQRCodePayload = suggestedQRCodePayload
|
||||
await signIn(with: suggestedQRCodePayload)
|
||||
func signInWithSuggestedPayload() async {
|
||||
manualPairingPayload = suggestedPairingPayload
|
||||
await signIn(with: suggestedPairingPayload, transport: .preview)
|
||||
}
|
||||
|
||||
func signIn(with payload: String) async {
|
||||
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
func signIn(
|
||||
with payload: String,
|
||||
transport: PairingTransport = .manual,
|
||||
signedGPSPosition: SignedGPSPosition? = nil
|
||||
) async {
|
||||
await signIn(
|
||||
with: PairingAuthenticationRequest(
|
||||
pairingPayload: payload,
|
||||
transport: transport,
|
||||
signedGPSPosition: signedGPSPosition
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func signIn(with request: PairingAuthenticationRequest) async {
|
||||
let trimmed = request.pairingPayload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
errorMessage = "Paste or scan a QR payload first."
|
||||
errorMessage = "Paste or scan a pairing payload first."
|
||||
return
|
||||
}
|
||||
|
||||
let normalizedRequest = PairingAuthenticationRequest(
|
||||
pairingPayload: trimmed,
|
||||
transport: request.transport,
|
||||
signedGPSPosition: request.signedGPSPosition
|
||||
)
|
||||
|
||||
isAuthenticating = true
|
||||
defer { isAuthenticating = false }
|
||||
|
||||
do {
|
||||
let result = try await service.signIn(withQRCode: trimmed)
|
||||
let result = try await service.signIn(with: normalizedRequest)
|
||||
session = result.session
|
||||
apply(snapshot: result.snapshot)
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
selectedSection = .overview
|
||||
bannerMessage = "Paired with \(result.session.deviceName)."
|
||||
errorMessage = nil
|
||||
isScannerPresented = false
|
||||
} catch let error as AppError {
|
||||
errorMessage = error.errorDescription
|
||||
@@ -135,6 +155,60 @@ final class AppViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func identifyWithNFC(_ request: PairingAuthenticationRequest) async {
|
||||
guard session != nil else {
|
||||
errorMessage = "Set up this passport before proving your identity with NFC."
|
||||
return
|
||||
}
|
||||
|
||||
await submitIdentityProof(
|
||||
payload: request.pairingPayload,
|
||||
transport: .nfc,
|
||||
signedGPSPosition: request.signedGPSPosition
|
||||
)
|
||||
}
|
||||
|
||||
func identifyWithPayload(_ payload: String, transport: PairingTransport = .qr) async {
|
||||
guard session != nil else {
|
||||
errorMessage = "Set up this passport before proving your identity."
|
||||
return
|
||||
}
|
||||
|
||||
await submitIdentityProof(payload: payload, transport: transport)
|
||||
}
|
||||
|
||||
private func submitIdentityProof(
|
||||
payload: String,
|
||||
transport: PairingTransport,
|
||||
signedGPSPosition: SignedGPSPosition? = nil
|
||||
) async {
|
||||
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
errorMessage = "The provided idp.global payload was empty."
|
||||
return
|
||||
}
|
||||
|
||||
let normalizedRequest = PairingAuthenticationRequest(
|
||||
pairingPayload: trimmed,
|
||||
transport: transport,
|
||||
signedGPSPosition: signedGPSPosition
|
||||
)
|
||||
|
||||
isIdentifying = true
|
||||
defer { isIdentifying = false }
|
||||
|
||||
do {
|
||||
let snapshot = try await service.identify(with: normalizedRequest)
|
||||
apply(snapshot: snapshot)
|
||||
errorMessage = nil
|
||||
isScannerPresented = false
|
||||
} catch let error as AppError {
|
||||
errorMessage = error.errorDescription
|
||||
} catch {
|
||||
errorMessage = "Unable to complete identity proof."
|
||||
}
|
||||
}
|
||||
|
||||
func refreshDashboard() async {
|
||||
guard session != nil else { return }
|
||||
|
||||
@@ -144,6 +218,7 @@ final class AppViewModel: ObservableObject {
|
||||
do {
|
||||
let snapshot = try await service.refreshDashboard()
|
||||
apply(snapshot: snapshot)
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to refresh the dashboard."
|
||||
}
|
||||
@@ -164,18 +239,16 @@ final class AppViewModel: ObservableObject {
|
||||
let snapshot = try await service.simulateIncomingRequest()
|
||||
apply(snapshot: snapshot)
|
||||
selectedSection = .requests
|
||||
bannerMessage = "A new mock approval request arrived."
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to seed a new request right now."
|
||||
errorMessage = "Unable to create a mock identity check right now."
|
||||
}
|
||||
}
|
||||
|
||||
func requestNotificationAccess() async {
|
||||
do {
|
||||
notificationPermission = try await notificationCoordinator.requestAuthorization()
|
||||
if notificationPermission == .allowed || notificationPermission == .provisional {
|
||||
bannerMessage = "Notifications are ready on this device."
|
||||
}
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to update notification permission."
|
||||
}
|
||||
@@ -184,11 +257,11 @@ final class AppViewModel: ObservableObject {
|
||||
func sendTestNotification() async {
|
||||
do {
|
||||
try await notificationCoordinator.scheduleTestNotification(
|
||||
title: "idp.global approval pending",
|
||||
body: "A mock request is waiting for approval in the app."
|
||||
title: "idp.global identity proof requested",
|
||||
body: "A mock identity proof request is waiting in the app."
|
||||
)
|
||||
bannerMessage = "A local test notification will appear in a few seconds."
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to schedule a test notification."
|
||||
}
|
||||
@@ -198,6 +271,7 @@ final class AppViewModel: ObservableObject {
|
||||
do {
|
||||
let snapshot = try await service.markNotificationRead(id: notification.id)
|
||||
apply(snapshot: snapshot)
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to update the notification."
|
||||
}
|
||||
@@ -209,8 +283,8 @@ final class AppViewModel: ObservableObject {
|
||||
requests = []
|
||||
notifications = []
|
||||
selectedSection = .overview
|
||||
bannerMessage = nil
|
||||
manualQRCodePayload = suggestedQRCodePayload
|
||||
manualPairingPayload = suggestedPairingPayload
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async {
|
||||
@@ -224,9 +298,9 @@ final class AppViewModel: ObservableObject {
|
||||
? try await service.approveRequest(id: request.id)
|
||||
: try await service.rejectRequest(id: request.id)
|
||||
apply(snapshot: snapshot)
|
||||
bannerMessage = approve ? "Request approved for \(request.source)." : "Request rejected for \(request.source)."
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to update the request."
|
||||
errorMessage = "Unable to update the identity check."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ struct IDPGlobalApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView(model: model)
|
||||
.tint(Color(red: 0.12, green: 0.40, blue: 0.31))
|
||||
.tint(AppTheme.accent)
|
||||
.task {
|
||||
await model.bootstrap()
|
||||
}
|
||||
@@ -47,17 +47,8 @@ private struct RootView: View {
|
||||
HomeRootView(model: model)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.96, green: 0.97, blue: 0.94),
|
||||
Color(red: 0.89, green: 0.94, blue: 0.92),
|
||||
Color(red: 0.94, green: 0.91, blue: 0.84)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
.background {
|
||||
AppBackground()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
enum AppSection: String, CaseIterable, Identifiable, Hashable {
|
||||
@@ -58,17 +59,119 @@ enum NotificationPermissionState: String, CaseIterable, Identifiable {
|
||||
case .unknown:
|
||||
"The app has not asked for notification delivery yet."
|
||||
case .allowed:
|
||||
"Alerts can break through immediately when a request arrives."
|
||||
"Identity proof alerts can break through immediately when a check arrives."
|
||||
case .provisional:
|
||||
"Notifications can be delivered quietly until the user promotes them."
|
||||
"Identity proof alerts can be delivered quietly until the user promotes them."
|
||||
case .denied:
|
||||
"Approval events stay in-app until the user re-enables notifications."
|
||||
"Identity proof events stay in-app until the user re-enables notifications."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BootstrapContext {
|
||||
let suggestedQRCodePayload: String
|
||||
let suggestedPairingPayload: String
|
||||
}
|
||||
|
||||
enum PairingTransport: String, Hashable {
|
||||
case qr
|
||||
case nfc
|
||||
case manual
|
||||
case preview
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .qr:
|
||||
"QR"
|
||||
case .nfc:
|
||||
"NFC"
|
||||
case .manual:
|
||||
"Manual"
|
||||
case .preview:
|
||||
"Preview"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PairingAuthenticationRequest: Hashable {
|
||||
let pairingPayload: String
|
||||
let transport: PairingTransport
|
||||
let signedGPSPosition: SignedGPSPosition?
|
||||
}
|
||||
|
||||
struct SignedGPSPosition: Hashable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let horizontalAccuracyMeters: Double
|
||||
let capturedAt: Date
|
||||
let signatureBase64: String
|
||||
let publicKeyBase64: String
|
||||
|
||||
init(
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
horizontalAccuracyMeters: Double,
|
||||
capturedAt: Date,
|
||||
signatureBase64: String = "",
|
||||
publicKeyBase64: String = ""
|
||||
) {
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.horizontalAccuracyMeters = horizontalAccuracyMeters
|
||||
self.capturedAt = capturedAt
|
||||
self.signatureBase64 = signatureBase64
|
||||
self.publicKeyBase64 = publicKeyBase64
|
||||
}
|
||||
|
||||
var coordinateSummary: String {
|
||||
"\(Self.normalized(latitude, precision: 5)), \(Self.normalized(longitude, precision: 5))"
|
||||
}
|
||||
|
||||
var accuracySummary: String {
|
||||
"±\(Int(horizontalAccuracyMeters.rounded())) m"
|
||||
}
|
||||
|
||||
func signingPayload(for pairingPayload: String) -> Data {
|
||||
let lines = [
|
||||
"payload=\(pairingPayload)",
|
||||
"latitude=\(Self.normalized(latitude, precision: 6))",
|
||||
"longitude=\(Self.normalized(longitude, precision: 6))",
|
||||
"accuracy=\(Self.normalized(horizontalAccuracyMeters, precision: 2))",
|
||||
"captured_at=\(Self.timestampFormatter.string(from: capturedAt))"
|
||||
]
|
||||
return Data(lines.joined(separator: "\n").utf8)
|
||||
}
|
||||
|
||||
func verified(for pairingPayload: String) -> Bool {
|
||||
guard let signatureData = Data(base64Encoded: signatureBase64),
|
||||
let publicKeyData = Data(base64Encoded: publicKeyBase64),
|
||||
let publicKey = try? P256.Signing.PublicKey(x963Representation: publicKeyData),
|
||||
let signature = try? P256.Signing.ECDSASignature(derRepresentation: signatureData) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return publicKey.isValidSignature(signature, for: signingPayload(for: pairingPayload))
|
||||
}
|
||||
|
||||
func signed(signatureData: Data, publicKeyData: Data) -> SignedGPSPosition {
|
||||
SignedGPSPosition(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
horizontalAccuracyMeters: horizontalAccuracyMeters,
|
||||
capturedAt: capturedAt,
|
||||
signatureBase64: signatureData.base64EncodedString(),
|
||||
publicKeyBase64: publicKeyData.base64EncodedString()
|
||||
)
|
||||
}
|
||||
|
||||
private static let timestampFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static func normalized(_ value: Double, precision: Int) -> String {
|
||||
String(format: "%.\(precision)f", locale: Locale(identifier: "en_US_POSIX"), value)
|
||||
}
|
||||
}
|
||||
|
||||
struct DashboardSnapshot {
|
||||
@@ -114,6 +217,8 @@ struct AuthSession: Identifiable, Hashable {
|
||||
let pairedAt: Date
|
||||
let tokenPreview: String
|
||||
let pairingCode: String
|
||||
let pairingTransport: PairingTransport
|
||||
let signedGPSPosition: SignedGPSPosition?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
@@ -121,7 +226,9 @@ struct AuthSession: Identifiable, Hashable {
|
||||
originHost: String,
|
||||
pairedAt: Date,
|
||||
tokenPreview: String,
|
||||
pairingCode: String
|
||||
pairingCode: String,
|
||||
pairingTransport: PairingTransport = .manual,
|
||||
signedGPSPosition: SignedGPSPosition? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.deviceName = deviceName
|
||||
@@ -129,6 +236,8 @@ struct AuthSession: Identifiable, Hashable {
|
||||
self.pairedAt = pairedAt
|
||||
self.tokenPreview = tokenPreview
|
||||
self.pairingCode = pairingCode
|
||||
self.pairingTransport = pairingTransport
|
||||
self.signedGPSPosition = signedGPSPosition
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,17 +248,17 @@ enum ApprovalRequestKind: String, CaseIterable, Hashable {
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .signIn: "Sign-In"
|
||||
case .accessGrant: "Access Grant"
|
||||
case .elevatedAction: "Elevated Action"
|
||||
case .signIn: "Identity Check"
|
||||
case .accessGrant: "Strong Proof"
|
||||
case .elevatedAction: "Sensitive Proof"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .signIn: "qrcode.viewfinder"
|
||||
case .accessGrant: "key.fill"
|
||||
case .elevatedAction: "shield.lefthalf.filled"
|
||||
case .accessGrant: "person.badge.shield.checkmark.fill"
|
||||
case .elevatedAction: "shield.checkered"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,18 +277,18 @@ enum ApprovalRisk: String, Hashable {
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .routine:
|
||||
"Routine access to profile or sign-in scopes."
|
||||
"A familiar identity proof for a normal sign-in or check."
|
||||
case .elevated:
|
||||
"Sensitive access that can sign, publish, or unlock privileged actions."
|
||||
"A higher-assurance identity proof for a sensitive check."
|
||||
}
|
||||
}
|
||||
|
||||
var guidance: String {
|
||||
switch self {
|
||||
case .routine:
|
||||
"Review the origin and scope list, then approve if the session matches the device you expect."
|
||||
"Review the origin and continue only if it matches the proof you started."
|
||||
case .elevated:
|
||||
"Treat this like a privileged operation. Verify the origin, the requested scopes, and whether the action is time-bound before approving."
|
||||
"Only continue if you initiated this proof and trust the origin asking for it."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,8 +301,8 @@ enum ApprovalStatus: String, Hashable {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pending: "Pending"
|
||||
case .approved: "Approved"
|
||||
case .rejected: "Rejected"
|
||||
case .approved: "Verified"
|
||||
case .rejected: "Declined"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,34 +350,34 @@ struct ApprovalRequest: Identifiable, Hashable {
|
||||
|
||||
var scopeSummary: String {
|
||||
if scopes.isEmpty {
|
||||
return "No scopes listed"
|
||||
return "No proof details listed"
|
||||
}
|
||||
|
||||
let suffix = scopes.count == 1 ? "" : "s"
|
||||
return "\(scopes.count) requested scope\(suffix)"
|
||||
return "\(scopes.count) proof detail\(suffix)"
|
||||
}
|
||||
|
||||
var trustHeadline: String {
|
||||
switch (kind, risk) {
|
||||
case (.signIn, .routine):
|
||||
"Low-friction sign-in request"
|
||||
"Standard identity proof"
|
||||
case (.signIn, .elevated):
|
||||
"Privileged sign-in request"
|
||||
"High-assurance sign-in proof"
|
||||
case (.accessGrant, _):
|
||||
"Token grant request"
|
||||
"Cross-device identity proof"
|
||||
case (.elevatedAction, _):
|
||||
"Sensitive action request"
|
||||
"Sensitive identity proof"
|
||||
}
|
||||
}
|
||||
|
||||
var trustDetail: String {
|
||||
switch kind {
|
||||
case .signIn:
|
||||
"This request usually creates or refreshes a session token for a browser, CLI, or device."
|
||||
"This request proves that the person at the browser, CLI, or device is really you."
|
||||
case .accessGrant:
|
||||
"This request issues scoped access for a service or automation that wants to act on your behalf."
|
||||
"This request asks for a stronger proof so the relying party can trust the session with higher confidence."
|
||||
case .elevatedAction:
|
||||
"This request performs a privileged action such as signing, publishing, or creating short-lived credentials."
|
||||
"This request asks for the highest confidence proof before continuing with a sensitive flow."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,7 +389,7 @@ enum AppNotificationKind: String, Hashable {
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .approval: "Approval"
|
||||
case .approval: "Proof"
|
||||
case .security: "Security"
|
||||
case .system: "System"
|
||||
}
|
||||
@@ -297,9 +406,9 @@ enum AppNotificationKind: String, Hashable {
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .approval:
|
||||
"Decision and approval activity"
|
||||
"Identity proof activity"
|
||||
case .security:
|
||||
"Pairing and security posture updates"
|
||||
"Passport and security posture updates"
|
||||
case .system:
|
||||
"Product and environment status messages"
|
||||
}
|
||||
@@ -332,15 +441,27 @@ struct AppNotification: Identifiable, Hashable {
|
||||
}
|
||||
|
||||
enum AppError: LocalizedError {
|
||||
case invalidQRCode
|
||||
case invalidPairingPayload
|
||||
case missingSignedGPSPosition
|
||||
case invalidSignedGPSPosition
|
||||
case locationPermissionDenied
|
||||
case locationUnavailable
|
||||
case requestNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidQRCode:
|
||||
"That QR payload is not valid for idp.global sign-in."
|
||||
case .invalidPairingPayload:
|
||||
"That idp.global payload is not valid for this action."
|
||||
case .missingSignedGPSPosition:
|
||||
"Tap NFC requires a signed GPS position."
|
||||
case .invalidSignedGPSPosition:
|
||||
"The signed GPS position attached to this NFC proof could not be verified."
|
||||
case .locationPermissionDenied:
|
||||
"Location access is required so Tap NFC can attach a signed GPS position."
|
||||
case .locationUnavailable:
|
||||
"Unable to determine the current GPS position for Tap NFC."
|
||||
case .requestNotFound:
|
||||
"The selected request could not be found."
|
||||
"The selected identity check could not be found."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import Foundation
|
||||
|
||||
protocol IDPServicing {
|
||||
func bootstrap() async throws -> BootstrapContext
|
||||
func signIn(withQRCode payload: String) async throws -> SignInResult
|
||||
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult
|
||||
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot
|
||||
func refreshDashboard() async throws -> DashboardSnapshot
|
||||
func approveRequest(id: UUID) async throws -> DashboardSnapshot
|
||||
func rejectRequest(id: UUID) async throws -> DashboardSnapshot
|
||||
@@ -30,18 +31,19 @@ actor MockIDPService: IDPServicing {
|
||||
func bootstrap() async throws -> BootstrapContext {
|
||||
try await Task.sleep(for: .milliseconds(120))
|
||||
return BootstrapContext(
|
||||
suggestedQRCodePayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
|
||||
suggestedPairingPayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
|
||||
)
|
||||
}
|
||||
|
||||
func signIn(withQRCode payload: String) async throws -> SignInResult {
|
||||
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult {
|
||||
try await Task.sleep(for: .milliseconds(260))
|
||||
|
||||
let session = try parseSession(from: payload)
|
||||
try validateSignedGPSPosition(in: request)
|
||||
let session = try parseSession(from: request)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "New device paired",
|
||||
message: "\(session.deviceName) completed a QR pairing against \(session.originHost).",
|
||||
title: "Passport activated",
|
||||
message: pairingMessage(for: session),
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
@@ -55,6 +57,25 @@ actor MockIDPService: IDPServicing {
|
||||
)
|
||||
}
|
||||
|
||||
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(180))
|
||||
|
||||
try validateSignedGPSPosition(in: request)
|
||||
let context = try parsePayloadContext(from: request.pairingPayload)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Identity proof completed",
|
||||
message: identificationMessage(for: context, signedGPSPosition: request.signedGPSPosition),
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
func refreshDashboard() async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(180))
|
||||
return snapshot()
|
||||
@@ -70,8 +91,8 @@ actor MockIDPService: IDPServicing {
|
||||
requests[index].status = .approved
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Request approved",
|
||||
message: "\(requests[index].title) was approved for \(requests[index].source).",
|
||||
title: "Identity verified",
|
||||
message: "\(requests[index].title) was completed for \(requests[index].source).",
|
||||
sentAt: .now,
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
@@ -92,8 +113,8 @@ actor MockIDPService: IDPServicing {
|
||||
requests[index].status = .rejected
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Request rejected",
|
||||
message: "\(requests[index].title) was rejected before token issuance.",
|
||||
title: "Identity proof declined",
|
||||
message: "\(requests[index].title) was declined before the session could continue.",
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
@@ -108,21 +129,21 @@ actor MockIDPService: IDPServicing {
|
||||
try await Task.sleep(for: .milliseconds(120))
|
||||
|
||||
let syntheticRequest = ApprovalRequest(
|
||||
title: "Approve SSH certificate issue",
|
||||
subtitle: "CI runner wants a short-lived signing certificate for a deployment pipeline.",
|
||||
source: "deploy.idp.global",
|
||||
title: "Prove identity for web sign-in",
|
||||
subtitle: "A browser session is asking this passport to prove that it is really you.",
|
||||
source: "auth.idp.global",
|
||||
createdAt: .now,
|
||||
kind: .elevatedAction,
|
||||
risk: .elevated,
|
||||
scopes: ["sign:ssh", "ttl:10m", "environment:staging"],
|
||||
kind: .signIn,
|
||||
risk: .routine,
|
||||
scopes: ["proof:basic", "client:web", "method:qr"],
|
||||
status: .pending
|
||||
)
|
||||
|
||||
requests.insert(syntheticRequest, at: 0)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Fresh approval request",
|
||||
message: "A staging deployment is waiting for your approval.",
|
||||
title: "Fresh identity proof request",
|
||||
message: "A new relying party is waiting for your identity proof.",
|
||||
sentAt: .now,
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
@@ -152,7 +173,33 @@ actor MockIDPService: IDPServicing {
|
||||
)
|
||||
}
|
||||
|
||||
private func parseSession(from payload: String) throws -> AuthSession {
|
||||
private func validateSignedGPSPosition(in request: PairingAuthenticationRequest) throws {
|
||||
if request.transport == .nfc,
|
||||
request.signedGPSPosition == nil {
|
||||
throw AppError.missingSignedGPSPosition
|
||||
}
|
||||
|
||||
if let signedGPSPosition = request.signedGPSPosition,
|
||||
!signedGPSPosition.verified(for: request.pairingPayload) {
|
||||
throw AppError.invalidSignedGPSPosition
|
||||
}
|
||||
}
|
||||
|
||||
private func parseSession(from request: PairingAuthenticationRequest) throws -> AuthSession {
|
||||
let context = try parsePayloadContext(from: request.pairingPayload)
|
||||
|
||||
return AuthSession(
|
||||
deviceName: context.deviceName,
|
||||
originHost: context.originHost,
|
||||
pairedAt: .now,
|
||||
tokenPreview: context.tokenPreview,
|
||||
pairingCode: request.pairingPayload,
|
||||
pairingTransport: request.transport,
|
||||
signedGPSPosition: request.signedGPSPosition
|
||||
)
|
||||
}
|
||||
|
||||
private func parsePayloadContext(from payload: String) throws -> PayloadContext {
|
||||
if let components = URLComponents(string: payload),
|
||||
components.scheme == "idp.global",
|
||||
components.host == "pair" {
|
||||
@@ -161,58 +208,88 @@ actor MockIDPService: IDPServicing {
|
||||
let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global"
|
||||
let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session"
|
||||
|
||||
return AuthSession(
|
||||
return PayloadContext(
|
||||
deviceName: device,
|
||||
originHost: origin,
|
||||
pairedAt: .now,
|
||||
tokenPreview: String(token.suffix(6)),
|
||||
pairingCode: payload
|
||||
tokenPreview: String(token.suffix(6))
|
||||
)
|
||||
}
|
||||
|
||||
if payload.contains("token") || payload.contains("pair") {
|
||||
return AuthSession(
|
||||
deviceName: "Manual Pairing",
|
||||
return PayloadContext(
|
||||
deviceName: "Manual Session",
|
||||
originHost: "code.foss.global",
|
||||
pairedAt: .now,
|
||||
tokenPreview: String(payload.suffix(6)),
|
||||
pairingCode: payload
|
||||
tokenPreview: String(payload.suffix(6))
|
||||
)
|
||||
}
|
||||
|
||||
throw AppError.invalidQRCode
|
||||
throw AppError.invalidPairingPayload
|
||||
}
|
||||
|
||||
private func pairingMessage(for session: AuthSession) -> String {
|
||||
let transportSummary: String
|
||||
switch session.pairingTransport {
|
||||
case .qr:
|
||||
transportSummary = "activated via QR"
|
||||
case .nfc:
|
||||
transportSummary = "activated via NFC with a signed GPS position"
|
||||
case .manual:
|
||||
transportSummary = "activated via manual payload"
|
||||
case .preview:
|
||||
transportSummary = "activated via preview payload"
|
||||
}
|
||||
|
||||
if let signedGPSPosition = session.signedGPSPosition {
|
||||
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
|
||||
}
|
||||
|
||||
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost)."
|
||||
}
|
||||
|
||||
private func identificationMessage(for context: PayloadContext, signedGPSPosition: SignedGPSPosition?) -> String {
|
||||
if let signedGPSPosition {
|
||||
return "A signed GPS proof was sent for \(context.deviceName) on \(context.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
|
||||
}
|
||||
|
||||
return "An identity proof was completed for \(context.deviceName) on \(context.originHost)."
|
||||
}
|
||||
|
||||
private struct PayloadContext {
|
||||
let deviceName: String
|
||||
let originHost: String
|
||||
let tokenPreview: String
|
||||
}
|
||||
|
||||
private static func seedRequests() -> [ApprovalRequest] {
|
||||
[
|
||||
ApprovalRequest(
|
||||
title: "Approve Safari sign-in",
|
||||
subtitle: "A browser session from Berlin wants an SSO token for the portal.",
|
||||
title: "Prove identity for Safari sign-in",
|
||||
subtitle: "The portal wants this passport to prove that the browser session is really you.",
|
||||
source: "code.foss.global",
|
||||
createdAt: .now.addingTimeInterval(-60 * 12),
|
||||
kind: .signIn,
|
||||
risk: .routine,
|
||||
scopes: ["openid", "profile", "groups:read"],
|
||||
scopes: ["proof:basic", "client:web", "origin:trusted"],
|
||||
status: .pending
|
||||
),
|
||||
ApprovalRequest(
|
||||
title: "Grant package publish access",
|
||||
subtitle: "The release bot is asking for a scoped publish token.",
|
||||
source: "registry.foss.global",
|
||||
title: "Prove identity for workstation unlock",
|
||||
subtitle: "Your secure workspace is asking for a stronger proof before it unlocks.",
|
||||
source: "berlin-mbp.idp.global",
|
||||
createdAt: .now.addingTimeInterval(-60 * 42),
|
||||
kind: .accessGrant,
|
||||
kind: .elevatedAction,
|
||||
risk: .elevated,
|
||||
scopes: ["packages:write", "ttl:30m"],
|
||||
scopes: ["proof:high", "client:desktop", "presence:required"],
|
||||
status: .pending
|
||||
),
|
||||
ApprovalRequest(
|
||||
title: "Approve CLI login",
|
||||
subtitle: "A terminal session completed QR pairing earlier today.",
|
||||
title: "Prove identity for CLI session",
|
||||
subtitle: "The CLI session asked for proof earlier and was completed from this passport.",
|
||||
source: "cli.idp.global",
|
||||
createdAt: .now.addingTimeInterval(-60 * 180),
|
||||
kind: .signIn,
|
||||
risk: .routine,
|
||||
scopes: ["openid", "profile"],
|
||||
scopes: ["proof:basic", "client:cli"],
|
||||
status: .approved
|
||||
)
|
||||
]
|
||||
@@ -221,8 +298,8 @@ actor MockIDPService: IDPServicing {
|
||||
private static func seedNotifications() -> [AppNotification] {
|
||||
[
|
||||
AppNotification(
|
||||
title: "Two requests are waiting",
|
||||
message: "The queue includes one routine sign-in and one elevated access grant.",
|
||||
title: "Two identity checks are waiting",
|
||||
message: "One routine web proof and one stronger workstation proof are waiting for this passport.",
|
||||
sentAt: .now.addingTimeInterval(-60 * 8),
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
@@ -235,8 +312,8 @@ actor MockIDPService: IDPServicing {
|
||||
isUnread: false
|
||||
),
|
||||
AppNotification(
|
||||
title: "Quiet hours active on mobile",
|
||||
message: "Routine notifications will be delivered silently until the morning.",
|
||||
title: "Passport quiet hours active",
|
||||
message: "Routine identity checks will be delivered silently until the morning.",
|
||||
sentAt: .now.addingTimeInterval(-60 * 220),
|
||||
kind: .security,
|
||||
isUnread: false
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
296
Sources/Features/Auth/NFCPairingView.swift
Normal file
296
Sources/Features/Auth/NFCPairingView.swift
Normal 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
|
||||
@@ -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 you’re 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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user