Move the app payload under swift/ while keeping git, package.json, and .smartconfig.json at the repo root. This standardizes the Swift app setup so build, test, run, and watch workflows match the other repos.
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import SwiftUI
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
private extension Color {
|
||||
static func adaptive(
|
||||
light: (red: Double, green: Double, blue: Double, opacity: Double),
|
||||
dark: (red: Double, green: Double, blue: Double, opacity: Double)
|
||||
) -> Color {
|
||||
#if os(macOS)
|
||||
Color(
|
||||
nsColor: NSColor(name: nil) { appearance in
|
||||
let matchedAppearance = appearance.bestMatch(from: [.darkAqua, .vibrantDark, .aqua, .vibrantLight])
|
||||
let components = matchedAppearance == .darkAqua || matchedAppearance == .vibrantDark ? dark : light
|
||||
return NSColor(
|
||||
red: components.red,
|
||||
green: components.green,
|
||||
blue: components.blue,
|
||||
alpha: components.opacity
|
||||
)
|
||||
}
|
||||
)
|
||||
#elseif canImport(UIKit) && !os(watchOS)
|
||||
Color(
|
||||
uiColor: UIColor { traits in
|
||||
let components = traits.userInterfaceStyle == .dark ? dark : light
|
||||
return UIColor(
|
||||
red: components.red,
|
||||
green: components.green,
|
||||
blue: components.blue,
|
||||
alpha: components.opacity
|
||||
)
|
||||
}
|
||||
)
|
||||
#elseif os(watchOS)
|
||||
Color(
|
||||
red: dark.red,
|
||||
green: dark.green,
|
||||
blue: dark.blue,
|
||||
opacity: dark.opacity
|
||||
)
|
||||
#else
|
||||
Color(
|
||||
red: light.red,
|
||||
green: light.green,
|
||||
blue: light.blue,
|
||||
opacity: light.opacity
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
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.adaptive(
|
||||
light: (0.00, 0.00, 0.00, 0.08),
|
||||
dark: (1.00, 1.00, 1.00, 0.12)
|
||||
)
|
||||
static let shadow = Color.adaptive(
|
||||
light: (0.00, 0.00, 0.00, 0.05),
|
||||
dark: (0.00, 0.00, 0.00, 0.32)
|
||||
)
|
||||
static let cardFill = Color.adaptive(
|
||||
light: (1.00, 1.00, 1.00, 0.96),
|
||||
dark: (0.11, 0.12, 0.14, 0.96)
|
||||
)
|
||||
static let mutedFill = Color.adaptive(
|
||||
light: (0.972, 0.976, 0.970, 1.00),
|
||||
dark: (0.16, 0.17, 0.19, 1.00)
|
||||
)
|
||||
static let backgroundTop = Color.adaptive(
|
||||
light: (0.975, 0.978, 0.972, 1.00),
|
||||
dark: (0.08, 0.09, 0.10, 1.00)
|
||||
)
|
||||
static let backgroundBottom = Color.adaptive(
|
||||
light: (1.00, 1.00, 1.00, 1.00),
|
||||
dark: (0.05, 0.06, 0.07, 1.00)
|
||||
)
|
||||
static let backgroundGlow = Color.adaptive(
|
||||
light: (0.00, 0.00, 0.00, 0.02),
|
||||
dark: (1.00, 1.00, 1.00, 0.06)
|
||||
)
|
||||
static let chromeFill = Color.adaptive(
|
||||
light: (1.00, 1.00, 1.00, 0.98),
|
||||
dark: (0.10, 0.11, 0.13, 0.98)
|
||||
)
|
||||
}
|
||||
|
||||
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: [
|
||||
AppTheme.backgroundTop,
|
||||
AppTheme.backgroundBottom
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(AppTheme.backgroundGlow)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AppViewModel: ObservableObject {
|
||||
@Published var suggestedPairingPayload = ""
|
||||
@Published var manualPairingPayload = ""
|
||||
@Published var session: AuthSession?
|
||||
@Published var profile: MemberProfile?
|
||||
@Published var requests: [ApprovalRequest] = []
|
||||
@Published var notifications: [AppNotification] = []
|
||||
@Published var notificationPermission: NotificationPermissionState = .unknown
|
||||
@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 errorMessage: String?
|
||||
|
||||
private var hasBootstrapped = false
|
||||
private let service: IDPServicing
|
||||
private let notificationCoordinator: NotificationCoordinating
|
||||
private let appStateStore: AppStateStoring
|
||||
private let launchArguments: [String]
|
||||
|
||||
private var preferredLaunchSection: AppSection? {
|
||||
guard let argument = launchArguments.first(where: { $0.hasPrefix("--mock-section=") }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let rawValue = String(argument.dropFirst("--mock-section=".count))
|
||||
if rawValue == "notifications" {
|
||||
return .activity
|
||||
}
|
||||
return AppSection(rawValue: rawValue)
|
||||
}
|
||||
|
||||
init(
|
||||
service: IDPServicing = MockIDPService(),
|
||||
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
|
||||
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
|
||||
launchArguments: [String] = ProcessInfo.processInfo.arguments
|
||||
) {
|
||||
self.service = service
|
||||
self.notificationCoordinator = notificationCoordinator
|
||||
self.appStateStore = appStateStore
|
||||
self.launchArguments = launchArguments
|
||||
}
|
||||
|
||||
var pendingRequests: [ApprovalRequest] {
|
||||
requests
|
||||
.filter { $0.status == .pending }
|
||||
.sorted { $0.createdAt > $1.createdAt }
|
||||
}
|
||||
|
||||
var handledRequests: [ApprovalRequest] {
|
||||
requests
|
||||
.filter { $0.status != .pending }
|
||||
.sorted { $0.createdAt > $1.createdAt }
|
||||
}
|
||||
|
||||
var unreadNotificationCount: Int {
|
||||
notifications.filter(\.isUnread).count
|
||||
}
|
||||
|
||||
var elevatedPendingCount: Int {
|
||||
pendingRequests.filter { $0.risk == .elevated }.count
|
||||
}
|
||||
|
||||
var latestNotification: AppNotification? {
|
||||
notifications.first
|
||||
}
|
||||
|
||||
var pairedDeviceSummary: String {
|
||||
session?.deviceName ?? "No active device"
|
||||
}
|
||||
|
||||
func bootstrap() async {
|
||||
guard !hasBootstrapped else { return }
|
||||
hasBootstrapped = true
|
||||
|
||||
restorePersistedState()
|
||||
|
||||
isBootstrapping = true
|
||||
defer { isBootstrapping = false }
|
||||
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
|
||||
do {
|
||||
let bootstrap = try await service.bootstrap()
|
||||
suggestedPairingPayload = bootstrap.suggestedPairingPayload
|
||||
manualPairingPayload = session?.pairingCode ?? bootstrap.suggestedPairingPayload
|
||||
|
||||
if launchArguments.contains("--mock-auto-pair"),
|
||||
session == nil {
|
||||
await signIn(with: bootstrap.suggestedPairingPayload, transport: .preview)
|
||||
|
||||
if let preferredLaunchSection {
|
||||
selectedSection = preferredLaunchSection
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if session == nil {
|
||||
errorMessage = "Unable to prepare the app."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func signInWithManualPayload() async {
|
||||
await signIn(with: manualPairingPayload, transport: .manual)
|
||||
}
|
||||
|
||||
func signInWithSuggestedPayload() async {
|
||||
manualPairingPayload = suggestedPairingPayload
|
||||
await signIn(with: suggestedPairingPayload, transport: .preview)
|
||||
}
|
||||
|
||||
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 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(with: normalizedRequest)
|
||||
session = result.session
|
||||
apply(snapshot: result.snapshot)
|
||||
persistCurrentState()
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
selectedSection = .overview
|
||||
errorMessage = nil
|
||||
isScannerPresented = false
|
||||
} catch let error as AppError {
|
||||
errorMessage = error.errorDescription
|
||||
} catch {
|
||||
errorMessage = "Unable to complete sign-in."
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
persistCurrentState()
|
||||
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 }
|
||||
|
||||
isRefreshing = true
|
||||
defer { isRefreshing = false }
|
||||
|
||||
do {
|
||||
let snapshot = try await service.refreshDashboard()
|
||||
apply(snapshot: snapshot)
|
||||
persistCurrentState()
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to refresh the dashboard."
|
||||
}
|
||||
}
|
||||
|
||||
func approve(_ request: ApprovalRequest) async {
|
||||
await mutateRequest(request, approve: true)
|
||||
}
|
||||
|
||||
func reject(_ request: ApprovalRequest) async {
|
||||
await mutateRequest(request, approve: false)
|
||||
}
|
||||
|
||||
func simulateIncomingRequest() async {
|
||||
guard session != nil else { return }
|
||||
|
||||
do {
|
||||
let snapshot = try await service.simulateIncomingRequest()
|
||||
apply(snapshot: snapshot)
|
||||
persistCurrentState()
|
||||
selectedSection = .requests
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to create a mock identity check right now."
|
||||
}
|
||||
}
|
||||
|
||||
func requestNotificationAccess() async {
|
||||
do {
|
||||
notificationPermission = try await notificationCoordinator.requestAuthorization()
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to update notification permission."
|
||||
}
|
||||
}
|
||||
|
||||
func sendTestNotification() async {
|
||||
do {
|
||||
try await notificationCoordinator.scheduleTestNotification(
|
||||
title: "idp.global identity proof requested",
|
||||
body: "A mock identity proof request is waiting in the app."
|
||||
)
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to schedule a test notification."
|
||||
}
|
||||
}
|
||||
|
||||
func markNotificationRead(_ notification: AppNotification) async {
|
||||
do {
|
||||
let snapshot = try await service.markNotificationRead(id: notification.id)
|
||||
apply(snapshot: snapshot)
|
||||
persistCurrentState()
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to update the notification."
|
||||
}
|
||||
}
|
||||
|
||||
func signOut() {
|
||||
appStateStore.clear()
|
||||
session = nil
|
||||
profile = nil
|
||||
requests = []
|
||||
notifications = []
|
||||
selectedSection = .overview
|
||||
manualPairingPayload = suggestedPairingPayload
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async {
|
||||
guard session != nil else { return }
|
||||
|
||||
activeRequestID = request.id
|
||||
defer { activeRequestID = nil }
|
||||
|
||||
do {
|
||||
let snapshot = approve
|
||||
? try await service.approveRequest(id: request.id)
|
||||
: try await service.rejectRequest(id: request.id)
|
||||
apply(snapshot: snapshot)
|
||||
persistCurrentState()
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to update the identity check."
|
||||
}
|
||||
}
|
||||
|
||||
private func restorePersistedState() {
|
||||
guard let state = appStateStore.load() else {
|
||||
return
|
||||
}
|
||||
|
||||
session = state.session
|
||||
manualPairingPayload = state.session.pairingCode
|
||||
apply(
|
||||
snapshot: DashboardSnapshot(
|
||||
profile: state.profile,
|
||||
requests: state.requests,
|
||||
notifications: state.notifications
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func persistCurrentState() {
|
||||
guard let session, let profile else {
|
||||
appStateStore.clear()
|
||||
return
|
||||
}
|
||||
|
||||
appStateStore.save(
|
||||
PersistedAppState(
|
||||
session: session,
|
||||
profile: profile,
|
||||
requests: requests,
|
||||
notifications: notifications
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func apply(snapshot: DashboardSnapshot) {
|
||||
profile = snapshot.profile
|
||||
requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
|
||||
notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct IDPGlobalApp: App {
|
||||
@StateObject private var model = AppViewModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView(model: model)
|
||||
.tint(AppTheme.accent)
|
||||
.task {
|
||||
await model.bootstrap()
|
||||
}
|
||||
.alert("Something went wrong", isPresented: errorPresented) {
|
||||
Button("OK") {
|
||||
model.errorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.defaultSize(width: 1380, height: 920)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var errorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
model.errorMessage = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RootView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.session == nil {
|
||||
LoginRootView(model: model)
|
||||
} else {
|
||||
HomeRootView(model: model)
|
||||
}
|
||||
}
|
||||
.background {
|
||||
AppBackground()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
enum AppSection: String, CaseIterable, Identifiable, Hashable, Codable {
|
||||
case overview
|
||||
case requests
|
||||
case activity
|
||||
case account
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .overview: "Passport"
|
||||
case .requests: "Requests"
|
||||
case .activity: "Activity"
|
||||
case .account: "Account"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .overview: "person.crop.square.fill"
|
||||
case .requests: "checklist.checked"
|
||||
case .activity: "clock.arrow.trianglehead.counterclockwise.rotate.90"
|
||||
case .account: "person.crop.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NotificationPermissionState: String, CaseIterable, Identifiable, Codable {
|
||||
case unknown
|
||||
case allowed
|
||||
case provisional
|
||||
case denied
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .unknown: "Not Asked Yet"
|
||||
case .allowed: "Enabled"
|
||||
case .provisional: "Delivered Quietly"
|
||||
case .denied: "Disabled"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .unknown: "bell"
|
||||
case .allowed: "bell.badge.fill"
|
||||
case .provisional: "bell.badge"
|
||||
case .denied: "bell.slash.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .unknown:
|
||||
"The app has not asked for notification delivery yet."
|
||||
case .allowed:
|
||||
"Identity proof alerts can break through immediately when a check arrives."
|
||||
case .provisional:
|
||||
"Identity proof alerts can be delivered quietly until the user promotes them."
|
||||
case .denied:
|
||||
"Identity proof events stay in-app until the user re-enables notifications."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BootstrapContext {
|
||||
let suggestedPairingPayload: String
|
||||
}
|
||||
|
||||
enum PairingTransport: String, Hashable, Codable {
|
||||
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, Codable {
|
||||
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 {
|
||||
let profile: MemberProfile
|
||||
let requests: [ApprovalRequest]
|
||||
let notifications: [AppNotification]
|
||||
}
|
||||
|
||||
struct SignInResult {
|
||||
let session: AuthSession
|
||||
let snapshot: DashboardSnapshot
|
||||
}
|
||||
|
||||
struct MemberProfile: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let handle: String
|
||||
let organization: String
|
||||
let deviceCount: Int
|
||||
let recoverySummary: String
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
handle: String,
|
||||
organization: String,
|
||||
deviceCount: Int,
|
||||
recoverySummary: String
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.handle = handle
|
||||
self.organization = organization
|
||||
self.deviceCount = deviceCount
|
||||
self.recoverySummary = recoverySummary
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthSession: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let deviceName: String
|
||||
let originHost: String
|
||||
let pairedAt: Date
|
||||
let tokenPreview: String
|
||||
let pairingCode: String
|
||||
let pairingTransport: PairingTransport
|
||||
let signedGPSPosition: SignedGPSPosition?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
deviceName: String,
|
||||
originHost: String,
|
||||
pairedAt: Date,
|
||||
tokenPreview: String,
|
||||
pairingCode: String,
|
||||
pairingTransport: PairingTransport = .manual,
|
||||
signedGPSPosition: SignedGPSPosition? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.deviceName = deviceName
|
||||
self.originHost = originHost
|
||||
self.pairedAt = pairedAt
|
||||
self.tokenPreview = tokenPreview
|
||||
self.pairingCode = pairingCode
|
||||
self.pairingTransport = pairingTransport
|
||||
self.signedGPSPosition = signedGPSPosition
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalRequestKind: String, CaseIterable, Hashable, Codable {
|
||||
case signIn
|
||||
case accessGrant
|
||||
case elevatedAction
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .signIn: "Identity Check"
|
||||
case .accessGrant: "Strong Proof"
|
||||
case .elevatedAction: "Sensitive Proof"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .signIn: "qrcode.viewfinder"
|
||||
case .accessGrant: "person.badge.shield.checkmark.fill"
|
||||
case .elevatedAction: "shield.checkered"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalRisk: String, Hashable, Codable {
|
||||
case routine
|
||||
case elevated
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .routine: "Routine"
|
||||
case .elevated: "Elevated"
|
||||
}
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .routine:
|
||||
"A familiar identity proof for a normal sign-in or check."
|
||||
case .elevated:
|
||||
"A higher-assurance identity proof for a sensitive check."
|
||||
}
|
||||
}
|
||||
|
||||
var guidance: String {
|
||||
switch self {
|
||||
case .routine:
|
||||
"Review the origin and continue only if it matches the proof you started."
|
||||
case .elevated:
|
||||
"Only continue if you initiated this proof and trust the origin asking for it."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalStatus: String, Hashable, Codable {
|
||||
case pending
|
||||
case approved
|
||||
case rejected
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pending: "Pending"
|
||||
case .approved: "Verified"
|
||||
case .rejected: "Declined"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .pending: "clock.badge"
|
||||
case .approved: "checkmark.circle.fill"
|
||||
case .rejected: "xmark.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ApprovalRequest: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let source: String
|
||||
let createdAt: Date
|
||||
let kind: ApprovalRequestKind
|
||||
let risk: ApprovalRisk
|
||||
let scopes: [String]
|
||||
var status: ApprovalStatus
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
title: String,
|
||||
subtitle: String,
|
||||
source: String,
|
||||
createdAt: Date,
|
||||
kind: ApprovalRequestKind,
|
||||
risk: ApprovalRisk,
|
||||
scopes: [String],
|
||||
status: ApprovalStatus
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.source = source
|
||||
self.createdAt = createdAt
|
||||
self.kind = kind
|
||||
self.risk = risk
|
||||
self.scopes = scopes
|
||||
self.status = status
|
||||
}
|
||||
|
||||
var scopeSummary: String {
|
||||
if scopes.isEmpty {
|
||||
return "No proof details listed"
|
||||
}
|
||||
|
||||
let suffix = scopes.count == 1 ? "" : "s"
|
||||
return "\(scopes.count) proof detail\(suffix)"
|
||||
}
|
||||
|
||||
var trustHeadline: String {
|
||||
switch (kind, risk) {
|
||||
case (.signIn, .routine):
|
||||
"Standard identity proof"
|
||||
case (.signIn, .elevated):
|
||||
"High-assurance sign-in proof"
|
||||
case (.accessGrant, _):
|
||||
"Cross-device identity proof"
|
||||
case (.elevatedAction, _):
|
||||
"Sensitive identity proof"
|
||||
}
|
||||
}
|
||||
|
||||
var trustDetail: String {
|
||||
switch kind {
|
||||
case .signIn:
|
||||
"This request proves that the person at the browser, CLI, or device is really you."
|
||||
case .accessGrant:
|
||||
"This request asks for a stronger proof so the relying party can trust the session with higher confidence."
|
||||
case .elevatedAction:
|
||||
"This request asks for the highest confidence proof before continuing with a sensitive flow."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppNotificationKind: String, Hashable, Codable {
|
||||
case approval
|
||||
case security
|
||||
case system
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .approval: "Proof"
|
||||
case .security: "Security"
|
||||
case .system: "System"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .approval: "checkmark.seal.fill"
|
||||
case .security: "shield.fill"
|
||||
case .system: "sparkles"
|
||||
}
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .approval:
|
||||
"Identity proof activity"
|
||||
case .security:
|
||||
"Passport and security posture updates"
|
||||
case .system:
|
||||
"Product and environment status messages"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppNotification: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let message: String
|
||||
let sentAt: Date
|
||||
let kind: AppNotificationKind
|
||||
var isUnread: Bool
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
title: String,
|
||||
message: String,
|
||||
sentAt: Date,
|
||||
kind: AppNotificationKind,
|
||||
isUnread: Bool
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.sentAt = sentAt
|
||||
self.kind = kind
|
||||
self.isUnread = isUnread
|
||||
}
|
||||
}
|
||||
|
||||
enum AppError: LocalizedError, Equatable {
|
||||
case invalidPairingPayload
|
||||
case missingSignedGPSPosition
|
||||
case invalidSignedGPSPosition
|
||||
case locationPermissionDenied
|
||||
case locationUnavailable
|
||||
case requestNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
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 identity check could not be found."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
struct PersistedAppState: Codable, Equatable {
|
||||
let session: AuthSession
|
||||
let profile: MemberProfile
|
||||
let requests: [ApprovalRequest]
|
||||
let notifications: [AppNotification]
|
||||
}
|
||||
|
||||
protocol AppStateStoring {
|
||||
func load() -> PersistedAppState?
|
||||
func save(_ state: PersistedAppState)
|
||||
func clear()
|
||||
}
|
||||
|
||||
final class UserDefaultsAppStateStore: AppStateStoring {
|
||||
private let defaults: UserDefaults
|
||||
private let storageKey: String
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
init(defaults: UserDefaults = .standard, storageKey: String = "persisted-app-state") {
|
||||
self.defaults = defaults
|
||||
self.storageKey = storageKey
|
||||
}
|
||||
|
||||
func load() -> PersistedAppState? {
|
||||
guard let data = defaults.data(forKey: storageKey) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return try? decoder.decode(PersistedAppState.self, from: data)
|
||||
}
|
||||
|
||||
func save(_ state: PersistedAppState) {
|
||||
guard let data = try? encoder.encode(state) else {
|
||||
return
|
||||
}
|
||||
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
defaults.removeObject(forKey: storageKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import Foundation
|
||||
|
||||
protocol IDPServicing {
|
||||
func bootstrap() async throws -> BootstrapContext
|
||||
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
|
||||
func simulateIncomingRequest() async throws -> DashboardSnapshot
|
||||
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot
|
||||
}
|
||||
|
||||
actor MockIDPService: IDPServicing {
|
||||
private let profile = MemberProfile(
|
||||
name: "Phil Kunz",
|
||||
handle: "phil@idp.global",
|
||||
organization: "idp.global",
|
||||
deviceCount: 4,
|
||||
recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified."
|
||||
)
|
||||
|
||||
private var requests: [ApprovalRequest] = []
|
||||
private var notifications: [AppNotification] = []
|
||||
|
||||
init() {
|
||||
requests = Self.seedRequests()
|
||||
notifications = Self.seedNotifications()
|
||||
}
|
||||
|
||||
func bootstrap() async throws -> BootstrapContext {
|
||||
try await Task.sleep(for: .milliseconds(120))
|
||||
return BootstrapContext(
|
||||
suggestedPairingPayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
|
||||
)
|
||||
}
|
||||
|
||||
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult {
|
||||
try await Task.sleep(for: .milliseconds(260))
|
||||
|
||||
try validateSignedGPSPosition(in: request)
|
||||
let session = try parseSession(from: request)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Passport activated",
|
||||
message: pairingMessage(for: session),
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return SignInResult(
|
||||
session: session,
|
||||
snapshot: snapshot()
|
||||
)
|
||||
}
|
||||
|
||||
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(180))
|
||||
|
||||
try validateSignedGPSPosition(in: request)
|
||||
let context = try PairingPayloadParser.parse(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()
|
||||
}
|
||||
|
||||
func approveRequest(id: UUID) async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(150))
|
||||
|
||||
guard let index = requests.firstIndex(where: { $0.id == id }) else {
|
||||
throw AppError.requestNotFound
|
||||
}
|
||||
|
||||
requests[index].status = .approved
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Identity verified",
|
||||
message: "\(requests[index].title) was completed for \(requests[index].source).",
|
||||
sentAt: .now,
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
func rejectRequest(id: UUID) async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(150))
|
||||
|
||||
guard let index = requests.firstIndex(where: { $0.id == id }) else {
|
||||
throw AppError.requestNotFound
|
||||
}
|
||||
|
||||
requests[index].status = .rejected
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Identity proof declined",
|
||||
message: "\(requests[index].title) was declined before the session could continue.",
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
func simulateIncomingRequest() async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(120))
|
||||
|
||||
let syntheticRequest = ApprovalRequest(
|
||||
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: .signIn,
|
||||
risk: .routine,
|
||||
scopes: ["proof:basic", "client:web", "method:qr"],
|
||||
status: .pending
|
||||
)
|
||||
|
||||
requests.insert(syntheticRequest, at: 0)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Fresh identity proof request",
|
||||
message: "A new relying party is waiting for your identity proof.",
|
||||
sentAt: .now,
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(80))
|
||||
|
||||
guard let index = notifications.firstIndex(where: { $0.id == id }) else {
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
notifications[index].isUnread = false
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
private func snapshot() -> DashboardSnapshot {
|
||||
DashboardSnapshot(
|
||||
profile: profile,
|
||||
requests: requests,
|
||||
notifications: notifications
|
||||
)
|
||||
}
|
||||
|
||||
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 PairingPayloadParser.parse(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 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: PairingPayloadContext, 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 static func seedRequests() -> [ApprovalRequest] {
|
||||
[
|
||||
ApprovalRequest(
|
||||
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: ["proof:basic", "client:web", "origin:trusted"],
|
||||
status: .pending
|
||||
),
|
||||
ApprovalRequest(
|
||||
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: .elevatedAction,
|
||||
risk: .elevated,
|
||||
scopes: ["proof:high", "client:desktop", "presence:required"],
|
||||
status: .pending
|
||||
),
|
||||
ApprovalRequest(
|
||||
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: ["proof:basic", "client:cli"],
|
||||
status: .approved
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private static func seedNotifications() -> [AppNotification] {
|
||||
[
|
||||
AppNotification(
|
||||
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
|
||||
),
|
||||
AppNotification(
|
||||
title: "Recovery health check passed",
|
||||
message: "Backup recovery channels were verified in the last 24 hours.",
|
||||
sentAt: .now.addingTimeInterval(-60 * 95),
|
||||
kind: .system,
|
||||
isUnread: false
|
||||
),
|
||||
AppNotification(
|
||||
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
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
protocol NotificationCoordinating {
|
||||
func authorizationStatus() async -> NotificationPermissionState
|
||||
func requestAuthorization() async throws -> NotificationPermissionState
|
||||
func scheduleTestNotification(title: String, body: String) async throws
|
||||
}
|
||||
|
||||
final class NotificationCoordinator: NotificationCoordinating {
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
|
||||
func authorizationStatus() async -> NotificationPermissionState {
|
||||
let settings = await center.notificationSettings()
|
||||
return NotificationPermissionState(settings.authorizationStatus)
|
||||
}
|
||||
|
||||
func requestAuthorization() async throws -> NotificationPermissionState {
|
||||
_ = try await center.requestAuthorization(options: [.alert, .badge, .sound])
|
||||
return await authorizationStatus()
|
||||
}
|
||||
|
||||
func scheduleTestNotification(title: String, body: String) async throws {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: UUID().uuidString,
|
||||
content: content,
|
||||
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3, repeats: false)
|
||||
)
|
||||
|
||||
try await center.add(request)
|
||||
}
|
||||
}
|
||||
|
||||
private extension NotificationPermissionState {
|
||||
init(_ status: UNAuthorizationStatus) {
|
||||
switch status {
|
||||
case .authorized:
|
||||
self = .allowed
|
||||
case .provisional, .ephemeral:
|
||||
self = .provisional
|
||||
case .denied:
|
||||
self = .denied
|
||||
case .notDetermined:
|
||||
self = .unknown
|
||||
@unknown default:
|
||||
self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
enum OneTimePasscodeGenerator {
|
||||
static func code(for pairingCode: String, at date: Date) -> String {
|
||||
let timeSlot = Int(date.timeIntervalSince1970 / 30)
|
||||
let digest = SHA256.hash(data: Data("\(pairingCode)|\(timeSlot)".utf8))
|
||||
let value = digest.prefix(4).reduce(UInt32(0)) { partialResult, byte in
|
||||
(partialResult << 8) | UInt32(byte)
|
||||
}
|
||||
|
||||
return String(format: "%06d", locale: Locale(identifier: "en_US_POSIX"), Int(value % 1_000_000))
|
||||
}
|
||||
|
||||
static func renewalCountdown(at date: Date) -> Int {
|
||||
let elapsed = Int(date.timeIntervalSince1970) % 30
|
||||
return elapsed == 0 ? 30 : 30 - elapsed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
struct PairingPayloadContext: Equatable {
|
||||
let deviceName: String
|
||||
let originHost: String
|
||||
let tokenPreview: String
|
||||
}
|
||||
|
||||
enum PairingPayloadParser {
|
||||
static func parse(_ payload: String) throws -> PairingPayloadContext {
|
||||
let trimmedPayload = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if let components = URLComponents(string: trimmedPayload),
|
||||
components.scheme == "idp.global",
|
||||
components.host == "pair" {
|
||||
let queryItems = components.queryItems ?? []
|
||||
let token = queryItems.first(where: { $0.name == "token" })?.value ?? "demo-token"
|
||||
let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global"
|
||||
let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session"
|
||||
|
||||
return PairingPayloadContext(
|
||||
deviceName: device,
|
||||
originHost: origin,
|
||||
tokenPreview: String(token.suffix(6))
|
||||
)
|
||||
}
|
||||
|
||||
if trimmedPayload.contains("token") || trimmedPayload.contains("pair") {
|
||||
return PairingPayloadContext(
|
||||
deviceName: "Manual Session",
|
||||
originHost: "code.foss.global",
|
||||
tokenPreview: String(trimmedPayload.suffix(6))
|
||||
)
|
||||
}
|
||||
|
||||
throw AppError.invalidPairingPayload
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import SwiftUI
|
||||
|
||||
private let loginAccent = AppTheme.accent
|
||||
|
||||
struct LoginRootView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
AppScrollScreen(compactLayout: compactLayout) {
|
||||
LoginHeroPanel(model: model, compactLayout: compactLayout)
|
||||
PairingConsoleCard(model: model, compactLayout: compactLayout)
|
||||
}
|
||||
.sheet(isPresented: $model.isScannerPresented) {
|
||||
QRScannerSheet(
|
||||
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.manualPairingPayload = payload
|
||||
Task {
|
||||
await model.signIn(with: payload, transport: .qr)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoginHeroPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: "Secure passport setup", tone: loginAccent)
|
||||
|
||||
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 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)
|
||||
|
||||
Divider()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PairingConsoleCard: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Text("NFC, QR, and OTP proof methods become available after this passport is active.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
primaryButtons
|
||||
secondaryButtons
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
primaryButtons
|
||||
}
|
||||
|
||||
secondaryButtons
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var primaryButtons: some View {
|
||||
Button {
|
||||
model.isScannerPresented = true
|
||||
} label: {
|
||||
Label("Scan QR", systemImage: "qrcode.viewfinder")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.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.signInWithManualPayload()
|
||||
}
|
||||
} label: {
|
||||
if model.isAuthenticating {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Label("Link with payload", systemImage: "arrow.right.circle")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
.disabled(model.isAuthenticating)
|
||||
}
|
||||
|
||||
private var previewPayloadButton: some View {
|
||||
Button {
|
||||
Task {
|
||||
await model.signInWithSuggestedPayload()
|
||||
}
|
||||
} label: {
|
||||
Label("Use preview passport", systemImage: "wand.and.stars")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,418 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
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 {
|
||||
AppScrollScreen(compactLayout: compactLayout) {
|
||||
AppSectionCard(title: title, compactLayout: compactLayout) {
|
||||
Text(description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
LiveQRScannerView(onCodeScanned: onCodeScanned)
|
||||
.frame(minHeight: 340)
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Manual fallback", compactLayout: compactLayout) {
|
||||
AppTextEditorField(text: $manualFallback, minHeight: 120)
|
||||
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
useFallbackButton
|
||||
useSeededButton
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
useFallbackButton
|
||||
useSeededButton
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(navigationTitleText)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
manualFallback = seededPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
let onCodeScanned: (String) -> Void
|
||||
|
||||
@StateObject private var scanner = QRScannerViewModel()
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
Group {
|
||||
if scanner.isPreviewAvailable {
|
||||
ScannerPreview(session: scanner.captureSession)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.fill(Color.black.opacity(0.86))
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Image(systemName: "video.slash.fill")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text("Live camera preview unavailable")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text(scanner.statusMessage)
|
||||
.foregroundStyle(.white.opacity(0.78))
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.22), lineWidth: 1.5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Camera Scanner")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text(scanner.statusMessage)
|
||||
.foregroundStyle(.white.opacity(0.84))
|
||||
}
|
||||
.padding(22)
|
||||
|
||||
ScanFrameOverlay()
|
||||
.padding(40)
|
||||
}
|
||||
.task {
|
||||
scanner.onCodeScanned = { payload in
|
||||
onCodeScanned(payload)
|
||||
}
|
||||
await scanner.start()
|
||||
}
|
||||
.onDisappear {
|
||||
scanner.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ScanFrameOverlay: View {
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let size = min(geometry.size.width, geometry.size.height) * 0.5
|
||||
|
||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.82), style: StrokeStyle(lineWidth: 3, dash: [10, 8]))
|
||||
.frame(width: size, height: size)
|
||||
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||
@Published var isPreviewAvailable = false
|
||||
@Published var statusMessage = "Point the camera at the QR code from the idp.global web portal."
|
||||
|
||||
let captureSession = AVCaptureSession()
|
||||
|
||||
var onCodeScanned: ((String) -> Void)?
|
||||
|
||||
private let queue = DispatchQueue(label: "global.idp.qrscanner")
|
||||
private var isConfigured = false
|
||||
private var hasDeliveredCode = false
|
||||
|
||||
func start() async {
|
||||
#if os(iOS) && targetEnvironment(simulator)
|
||||
await MainActor.run {
|
||||
isPreviewAvailable = false
|
||||
statusMessage = "The iOS simulator has no live camera feed. Use the seeded payload below."
|
||||
}
|
||||
#else
|
||||
#endif
|
||||
|
||||
#if !(os(iOS) && targetEnvironment(simulator))
|
||||
let authorization = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
switch authorization {
|
||||
case .authorized:
|
||||
await configureIfNeeded()
|
||||
startRunning()
|
||||
case .notDetermined:
|
||||
let granted = await requestCameraAccess()
|
||||
await MainActor.run {
|
||||
self.statusMessage = granted
|
||||
? "Point the camera at the QR code from the idp.global web portal."
|
||||
: "Camera access was denied. Use the fallback payload below."
|
||||
}
|
||||
guard granted else { return }
|
||||
await configureIfNeeded()
|
||||
startRunning()
|
||||
case .denied, .restricted:
|
||||
await MainActor.run {
|
||||
isPreviewAvailable = false
|
||||
statusMessage = "Camera access is unavailable. Use the fallback payload below."
|
||||
}
|
||||
@unknown default:
|
||||
await MainActor.run {
|
||||
isPreviewAvailable = false
|
||||
statusMessage = "Camera access could not be initialized on this device."
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue.async {
|
||||
if self.captureSession.isRunning {
|
||||
self.captureSession.stopRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func metadataOutput(
|
||||
_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
guard !hasDeliveredCode,
|
||||
let readable = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
readable.type == .qr,
|
||||
let payload = readable.stringValue else {
|
||||
return
|
||||
}
|
||||
|
||||
hasDeliveredCode = true
|
||||
stop()
|
||||
|
||||
#if os(iOS)
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.async { [onCodeScanned] in
|
||||
onCodeScanned?(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestCameraAccess() async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
AVCaptureDevice.requestAccess(for: .video) { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureIfNeeded() async {
|
||||
guard !isConfigured else {
|
||||
await MainActor.run {
|
||||
self.isPreviewAvailable = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
queue.async {
|
||||
self.captureSession.beginConfiguration()
|
||||
defer {
|
||||
self.captureSession.commitConfiguration()
|
||||
continuation.resume()
|
||||
}
|
||||
|
||||
guard let device = AVCaptureDevice.default(for: .video) else {
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "No compatible camera was found. Use the fallback payload below."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let input = try? AVCaptureDeviceInput(device: device) else {
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "No compatible camera was found. Use the fallback payload below."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard self.captureSession.canAddInput(input) else {
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "No compatible camera was found. Use the fallback payload below."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.captureSession.addInput(input)
|
||||
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard self.captureSession.canAddOutput(output) else {
|
||||
self.captureSession.removeInput(input)
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "Unable to configure QR metadata scanning on this device."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.captureSession.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||
|
||||
let supportedTypes = output.availableMetadataObjectTypes
|
||||
guard supportedTypes.contains(.qr) else {
|
||||
self.captureSession.removeOutput(output)
|
||||
self.captureSession.removeInput(input)
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "This camera does not support QR metadata scanning. Use the fallback payload below."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
output.metadataObjectTypes = [.qr]
|
||||
self.isConfigured = true
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = true
|
||||
self.statusMessage = "Point the camera at the QR code from the idp.global web portal."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startRunning() {
|
||||
queue.async {
|
||||
guard !self.captureSession.isRunning else { return }
|
||||
self.hasDeliveredCode = false
|
||||
self.captureSession.startRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension QRScannerViewModel: @unchecked Sendable {}
|
||||
|
||||
#if os(iOS)
|
||||
private struct ScannerPreview: UIViewRepresentable {
|
||||
let session: AVCaptureSession
|
||||
|
||||
func makeUIView(context: Context) -> ScannerPreviewUIView {
|
||||
let view = ScannerPreviewUIView()
|
||||
view.previewLayer.session = session
|
||||
view.previewLayer.videoGravity = .resizeAspectFill
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: ScannerPreviewUIView, context: Context) {
|
||||
uiView.previewLayer.session = session
|
||||
}
|
||||
}
|
||||
|
||||
private final class ScannerPreviewUIView: UIView {
|
||||
override class var layerClass: AnyClass {
|
||||
AVCaptureVideoPreviewLayer.self
|
||||
}
|
||||
|
||||
var previewLayer: AVCaptureVideoPreviewLayer {
|
||||
layer as! AVCaptureVideoPreviewLayer
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
private struct ScannerPreview: NSViewRepresentable {
|
||||
let session: AVCaptureSession
|
||||
|
||||
func makeNSView(context: Context) -> ScannerPreviewNSView {
|
||||
let view = ScannerPreviewNSView()
|
||||
view.attach(session: session)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: ScannerPreviewNSView, context: Context) {
|
||||
nsView.attach(session: session)
|
||||
}
|
||||
}
|
||||
|
||||
private final class ScannerPreviewNSView: NSView {
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
wantsLayer = true
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
wantsLayer = true
|
||||
}
|
||||
|
||||
func attach(session: AVCaptureSession) {
|
||||
let layer = previewLayer ?? AVCaptureVideoPreviewLayer(session: session)
|
||||
layer.session = session
|
||||
layer.videoGravity = .resizeAspectFill
|
||||
self.layer = layer
|
||||
previewLayer = layer
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,330 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RequestList: View {
|
||||
let requests: [ApprovalRequest]
|
||||
let compactLayout: Bool
|
||||
let activeRequestID: ApprovalRequest.ID?
|
||||
let onApprove: ((ApprovalRequest) -> Void)?
|
||||
let onReject: ((ApprovalRequest) -> Void)?
|
||||
let onOpenRequest: (ApprovalRequest) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
ForEach(requests) { request in
|
||||
RequestCard(
|
||||
request: request,
|
||||
compactLayout: compactLayout,
|
||||
isBusy: activeRequestID == request.id,
|
||||
onApprove: onApprove == nil ? nil : { onApprove?(request) },
|
||||
onReject: onReject == nil ? nil : { onReject?(request) },
|
||||
onOpenRequest: { onOpenRequest(request) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RequestCard: View {
|
||||
let request: ApprovalRequest
|
||||
let compactLayout: Bool
|
||||
let isBusy: Bool
|
||||
let onApprove: (() -> Void)?
|
||||
let onReject: (() -> Void)?
|
||||
let onOpenRequest: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: request.kind.systemImage)
|
||||
.font(.headline)
|
||||
.foregroundStyle(requestAccent)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(request.title)
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Text(request.source)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
AppStatusTag(title: request.status.title, tone: statusTone)
|
||||
}
|
||||
|
||||
Text(request.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
|
||||
Text(request.scopeSummary)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 0)
|
||||
Text(request.createdAt, style: .relative)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !request.scopes.isEmpty {
|
||||
Text("Proof details: \(request.scopes.joined(separator: ", "))")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
controls
|
||||
}
|
||||
.padding(compactLayout ? 18 : 20)
|
||||
.appSurface(radius: 24)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var controls: some View {
|
||||
if compactLayout {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
reviewButton
|
||||
decisionButtons
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
reviewButton
|
||||
Spacer(minLength: 0)
|
||||
decisionButtons
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var reviewButton: some View {
|
||||
Button {
|
||||
onOpenRequest()
|
||||
} label: {
|
||||
Label("Review proof", systemImage: "arrow.up.forward.app")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var decisionButtons: some View {
|
||||
if request.status == .pending, let onApprove, let onReject {
|
||||
Button {
|
||||
onApprove()
|
||||
} label: {
|
||||
if isBusy {
|
||||
ProgressView()
|
||||
} else {
|
||||
Label("Verify", systemImage: "checkmark.circle.fill")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isBusy)
|
||||
|
||||
Button(role: .destructive) {
|
||||
onReject()
|
||||
} label: {
|
||||
Label("Decline", systemImage: "xmark.circle.fill")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusTone: Color {
|
||||
switch request.status {
|
||||
case .pending:
|
||||
.orange
|
||||
case .approved:
|
||||
.green
|
||||
case .rejected:
|
||||
.red
|
||||
}
|
||||
}
|
||||
|
||||
private var requestAccent: Color {
|
||||
switch request.status {
|
||||
case .approved:
|
||||
.green
|
||||
case .rejected:
|
||||
.red
|
||||
case .pending:
|
||||
request.risk == .routine ? dashboardAccent : .orange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationList: View {
|
||||
let notifications: [AppNotification]
|
||||
let compactLayout: Bool
|
||||
let onMarkRead: (AppNotification) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
ForEach(notifications) { notification in
|
||||
NotificationCard(
|
||||
notification: notification,
|
||||
compactLayout: compactLayout,
|
||||
onMarkRead: { onMarkRead(notification) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationCard: View {
|
||||
let notification: AppNotification
|
||||
let compactLayout: Bool
|
||||
let onMarkRead: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: notification.kind.systemImage)
|
||||
.font(.headline)
|
||||
.foregroundStyle(accentColor)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(notification.title)
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: notification.kind.title, tone: accentColor)
|
||||
if notification.isUnread {
|
||||
AppStatusTag(title: "Unread", tone: .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
Text(notification.message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if compactLayout {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
timestamp
|
||||
if notification.isUnread {
|
||||
markReadButton
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
timestamp
|
||||
Spacer(minLength: 0)
|
||||
if notification.isUnread {
|
||||
markReadButton
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(compactLayout ? 18 : 20)
|
||||
.appSurface(radius: 24)
|
||||
}
|
||||
|
||||
private var timestamp: some View {
|
||||
Text(notification.sentAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var markReadButton: some View {
|
||||
Button {
|
||||
onMarkRead()
|
||||
} label: {
|
||||
Label("Mark read", systemImage: "checkmark")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
private var accentColor: Color {
|
||||
switch notification.kind {
|
||||
case .approval:
|
||||
.green
|
||||
case .security:
|
||||
.orange
|
||||
case .system:
|
||||
.blue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationBellButton: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
model.isNotificationCenterPresented = true
|
||||
} label: {
|
||||
Image(systemName: imageName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(iconTone)
|
||||
.frame(width: 28, height: 28, alignment: .center)
|
||||
.background(alignment: .center) {
|
||||
#if os(iOS)
|
||||
GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: NotificationBellFrameKey.self, value: proxy.frame(in: .global))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Notifications")
|
||||
}
|
||||
|
||||
private var imageName: String {
|
||||
#if os(iOS)
|
||||
model.unreadNotificationCount == 0 ? "bell" : "bell.fill"
|
||||
#else
|
||||
model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill"
|
||||
#endif
|
||||
}
|
||||
|
||||
private var iconTone: some ShapeStyle {
|
||||
model.unreadNotificationCount == 0 ? Color.primary : dashboardAccent
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationCenterSheet: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
AppScrollScreen(
|
||||
compactLayout: compactLayout,
|
||||
bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding
|
||||
) {
|
||||
NotificationsPanel(model: model, compactLayout: compactLayout)
|
||||
}
|
||||
.navigationTitle("Notifications")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.presentationDetents(compactLayout ? [.large] : [.medium, .large])
|
||||
#endif
|
||||
}
|
||||
|
||||
private var compactLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OverviewPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if let profile = model.profile, let session = model.session {
|
||||
OverviewHero(
|
||||
profile: profile,
|
||||
session: session,
|
||||
pendingCount: model.pendingRequests.count,
|
||||
unreadCount: model.unreadNotificationCount,
|
||||
compactLayout: compactLayout
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RequestsPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
let onOpenRequest: (ApprovalRequest) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if model.requests.isEmpty {
|
||||
AppPanel(compactLayout: compactLayout) {
|
||||
EmptyStateCopy(
|
||||
title: "No checks waiting",
|
||||
systemImage: "checkmark.circle",
|
||||
message: "Identity proof requests from sites and devices appear here."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
RequestList(
|
||||
requests: model.requests,
|
||||
compactLayout: compactLayout,
|
||||
activeRequestID: model.activeRequestID,
|
||||
onApprove: { request in
|
||||
Task { await model.approve(request) }
|
||||
},
|
||||
onReject: { request in
|
||||
Task { await model.reject(request) }
|
||||
},
|
||||
onOpenRequest: onOpenRequest
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActivityPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if model.notifications.isEmpty {
|
||||
AppPanel(compactLayout: compactLayout) {
|
||||
EmptyStateCopy(
|
||||
title: "No proof activity yet",
|
||||
systemImage: "clock.badge.xmark",
|
||||
message: "Identity proofs and security events will appear here."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
NotificationList(
|
||||
notifications: model.notifications,
|
||||
compactLayout: compactLayout,
|
||||
onMarkRead: { notification in
|
||||
Task { await model.markNotificationRead(notification) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationsPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
AppSectionCard(title: "Delivery", compactLayout: compactLayout) {
|
||||
NotificationPermissionSummary(model: model, compactLayout: compactLayout)
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Alerts", compactLayout: compactLayout) {
|
||||
if model.notifications.isEmpty {
|
||||
EmptyStateCopy(
|
||||
title: "No alerts yet",
|
||||
systemImage: "bell.slash",
|
||||
message: "New passport and identity-proof alerts will accumulate here."
|
||||
)
|
||||
} else {
|
||||
NotificationList(
|
||||
notifications: model.notifications,
|
||||
compactLayout: compactLayout,
|
||||
onMarkRead: { notification in
|
||||
Task { await model.markNotificationRead(notification) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if let profile = model.profile, let session = model.session {
|
||||
AccountHero(profile: profile, session: session, compactLayout: compactLayout)
|
||||
|
||||
AppSectionCard(title: "Session", compactLayout: compactLayout) {
|
||||
AccountFactsGrid(profile: profile, session: session, compactLayout: compactLayout)
|
||||
}
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Pairing payload", compactLayout: compactLayout) {
|
||||
AppTextSurface(text: model.suggestedPairingPayload, monospaced: true)
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Actions", compactLayout: compactLayout) {
|
||||
Button(role: .destructive) {
|
||||
model.signOut()
|
||||
} label: {
|
||||
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OverviewHero: View {
|
||||
let profile: MemberProfile
|
||||
let session: AuthSession
|
||||
let pendingCount: Int
|
||||
let unreadCount: Int
|
||||
let compactLayout: Bool
|
||||
|
||||
private var detailColumns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
|
||||
}
|
||||
|
||||
private var metricColumns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 16), count: 3)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: "Digital passport", tone: dashboardAccent)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(profile.name)
|
||||
.font(.system(size: compactLayout ? 30 : 38, weight: .bold, design: .rounded))
|
||||
.lineLimit(2)
|
||||
|
||||
Text("\(profile.handle) • \(profile.organization)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: "Passport active", tone: dashboardAccent)
|
||||
AppStatusTag(title: session.pairingTransport.title, tone: dashboardGold)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
LazyVGrid(columns: detailColumns, alignment: .leading, spacing: 16) {
|
||||
AppKeyValue(label: "Device", value: session.deviceName)
|
||||
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
|
||||
AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
LazyVGrid(columns: metricColumns, alignment: .leading, spacing: 16) {
|
||||
AppMetric(title: "Pending", value: "\(pendingCount)")
|
||||
AppMetric(title: "Alerts", value: "\(unreadCount)")
|
||||
AppMetric(title: "Devices", value: "\(profile.deviceCount)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationPermissionSummary: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: model.notificationPermission.systemImage)
|
||||
.font(.headline)
|
||||
.foregroundStyle(dashboardAccent)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(model.notificationPermission.title)
|
||||
.font(.headline)
|
||||
Text(model.notificationPermission.summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if compactLayout {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
permissionButtons
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
permissionButtons
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var permissionButtons: some View {
|
||||
Button {
|
||||
Task { await model.requestNotificationAccess() }
|
||||
} label: {
|
||||
Label("Enable notifications", systemImage: "bell.and.waves.left.and.right.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
Task { await model.sendTestNotification() }
|
||||
} label: {
|
||||
Label("Send test alert", systemImage: "paperplane.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountHero: View {
|
||||
let profile: MemberProfile
|
||||
let session: AuthSession
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: "Account", tone: dashboardAccent)
|
||||
|
||||
Text(profile.name)
|
||||
.font(.system(size: compactLayout ? 28 : 34, weight: .bold, design: .rounded))
|
||||
.lineLimit(2)
|
||||
|
||||
Text(profile.handle)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Active client: \(session.deviceName)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountFactsGrid: View {
|
||||
let profile: MemberProfile
|
||||
let session: AuthSession
|
||||
let compactLayout: Bool
|
||||
|
||||
private var columns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: columns, alignment: .leading, spacing: 16) {
|
||||
AppKeyValue(label: "Organization", value: profile.organization)
|
||||
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
|
||||
AppKeyValue(label: "Linked At", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
AppKeyValue(label: "Method", value: session.pairingTransport.title)
|
||||
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
|
||||
AppKeyValue(label: "Recovery", value: profile.recoverySummary)
|
||||
if let signedGPSPosition = session.signedGPSPosition {
|
||||
AppKeyValue(
|
||||
label: "Signed GPS",
|
||||
value: "\(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)",
|
||||
monospaced: true
|
||||
)
|
||||
}
|
||||
AppKeyValue(label: "Trusted Devices", value: "\(profile.deviceCount)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyStateCopy: View {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView(
|
||||
title,
|
||||
systemImage: systemImage,
|
||||
description: Text(message)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import SwiftUI
|
||||
|
||||
let dashboardAccent = AppTheme.accent
|
||||
let dashboardGold = AppTheme.warmAccent
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func inlineNavigationTitleOnIOS() -> some View {
|
||||
#if os(iOS)
|
||||
navigationBarTitleDisplayMode(.inline)
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func cleanTabBarOnIOS() -> some View {
|
||||
#if os(iOS)
|
||||
toolbarBackground(.visible, for: .tabBar)
|
||||
.toolbarBackground(AppTheme.chromeFill, for: .tabBar)
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct HomeRootView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
@State private var notificationBellFrame: CGRect?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if usesCompactNavigation {
|
||||
CompactHomeContainer(model: model)
|
||||
} else {
|
||||
RegularHomeContainer(model: model)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(NotificationBellFrameKey.self) { notificationBellFrame = $0 }
|
||||
.overlay(alignment: .topLeading) {
|
||||
if usesCompactNavigation {
|
||||
NotificationBellBadgeOverlay(
|
||||
unreadCount: model.unreadNotificationCount,
|
||||
bellFrame: notificationBellFrame
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $model.isNotificationCenterPresented) {
|
||||
NotificationCenterSheet(model: model)
|
||||
}
|
||||
}
|
||||
|
||||
private var usesCompactNavigation: Bool {
|
||||
#if os(iOS)
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct CompactHomeContainer: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $model.selectedSection) {
|
||||
ForEach(AppSection.allCases) { section in
|
||||
NavigationStack {
|
||||
HomeSectionScreen(model: model, section: section, compactLayout: compactLayout)
|
||||
.navigationTitle(section.title)
|
||||
.inlineNavigationTitleOnIOS()
|
||||
.toolbar {
|
||||
DashboardToolbar(model: model)
|
||||
}
|
||||
}
|
||||
.tag(section)
|
||||
.tabItem {
|
||||
Label(section.title, systemImage: section.systemImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
.cleanTabBarOnIOS()
|
||||
}
|
||||
|
||||
private var compactLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct RegularHomeContainer: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
Sidebar(model: model)
|
||||
.navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 320)
|
||||
} detail: {
|
||||
HomeSectionScreen(model: model, section: model.selectedSection, compactLayout: false)
|
||||
.navigationTitle(model.selectedSection.title)
|
||||
.toolbar {
|
||||
DashboardToolbar(model: model)
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DashboardToolbar: ToolbarContent {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some ToolbarContent {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
NotificationBellButton(model: model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationBellFrameKey: PreferenceKey {
|
||||
static var defaultValue: CGRect? = nil
|
||||
|
||||
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
|
||||
value = nextValue() ?? value
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationBellBadgeOverlay: View {
|
||||
let unreadCount: Int
|
||||
let bellFrame: CGRect?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
if unreadCount > 0, let bellFrame {
|
||||
let rootFrame = proxy.frame(in: .global)
|
||||
|
||||
Text("\(min(unreadCount, 9))")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(minWidth: 18, minHeight: 18)
|
||||
.padding(.horizontal, 3)
|
||||
.background(Color.orange, in: Capsule())
|
||||
.position(
|
||||
x: bellFrame.maxX - rootFrame.minX - 2,
|
||||
y: bellFrame.minY - rootFrame.minY + 2
|
||||
)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeSectionScreen: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let section: AppSection
|
||||
let compactLayout: Bool
|
||||
|
||||
@State private var focusedRequest: ApprovalRequest?
|
||||
@State private var isOTPPresented = false
|
||||
@StateObject private var identifyReader = NFCIdentifyReader()
|
||||
|
||||
var body: some View {
|
||||
AppScrollScreen(
|
||||
compactLayout: compactLayout,
|
||||
bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding
|
||||
) {
|
||||
HomeTopActions(
|
||||
model: model,
|
||||
identifyReader: identifyReader,
|
||||
onScanQR: { model.isScannerPresented = true },
|
||||
onShowOTP: { isOTPPresented = true }
|
||||
)
|
||||
|
||||
switch section {
|
||||
case .overview:
|
||||
OverviewPanel(model: model, compactLayout: compactLayout)
|
||||
case .requests:
|
||||
RequestsPanel(model: model, compactLayout: compactLayout, onOpenRequest: { focusedRequest = $0 })
|
||||
case .activity:
|
||||
ActivityPanel(model: model, compactLayout: compactLayout)
|
||||
case .account:
|
||||
AccountPanel(model: model, compactLayout: compactLayout)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
identifyReader.onAuthenticationRequestDetected = { request in
|
||||
Task {
|
||||
await model.identifyWithNFC(request)
|
||||
}
|
||||
}
|
||||
|
||||
identifyReader.onError = { message in
|
||||
model.errorMessage = message
|
||||
}
|
||||
}
|
||||
.sheet(item: $focusedRequest) { request in
|
||||
RequestDetailSheet(request: request, model: model)
|
||||
}
|
||||
.sheet(isPresented: $model.isScannerPresented) {
|
||||
QRScannerSheet(
|
||||
seededPayload: model.session?.pairingCode ?? model.suggestedPairingPayload,
|
||||
title: "Scan proof QR",
|
||||
description: "Use the camera to scan an idp.global QR challenge from the site or device asking you to prove that it is really you.",
|
||||
navigationTitle: "Scan Proof QR",
|
||||
onCodeScanned: { payload in
|
||||
Task {
|
||||
await model.identifyWithPayload(payload, transport: .qr)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $isOTPPresented) {
|
||||
if let session = model.session {
|
||||
OneTimePasscodeSheet(session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeTopActions: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
@ObservedObject var identifyReader: NFCIdentifyReader
|
||||
let onScanQR: () -> Void
|
||||
let onShowOTP: () -> Void
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: columns, spacing: 12) {
|
||||
identifyButton
|
||||
qrButton
|
||||
otpButton
|
||||
}
|
||||
}
|
||||
|
||||
private var columns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 12), count: 3)
|
||||
}
|
||||
|
||||
private var identifyButton: some View {
|
||||
Button {
|
||||
identifyReader.beginScanning()
|
||||
} label: {
|
||||
AppActionTile(
|
||||
title: identifyReader.isScanning ? "Scanning NFC" : "Tap NFC",
|
||||
systemImage: "dot.radiowaves.left.and.right",
|
||||
tone: dashboardAccent,
|
||||
isBusy: identifyReader.isScanning || model.isIdentifying
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(identifyReader.isScanning || !identifyReader.isSupported || model.isIdentifying)
|
||||
}
|
||||
|
||||
private var qrButton: some View {
|
||||
Button {
|
||||
onScanQR()
|
||||
} label: {
|
||||
AppActionTile(
|
||||
title: "Scan QR",
|
||||
systemImage: "qrcode.viewfinder",
|
||||
tone: dashboardAccent
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var otpButton: some View {
|
||||
Button {
|
||||
onShowOTP()
|
||||
} label: {
|
||||
AppActionTile(
|
||||
title: "OTP",
|
||||
systemImage: "number.square.fill",
|
||||
tone: dashboardGold
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct Sidebar: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
SidebarStatusCard(
|
||||
profile: model.profile,
|
||||
pendingCount: model.pendingRequests.count,
|
||||
unreadCount: model.unreadNotificationCount
|
||||
)
|
||||
}
|
||||
|
||||
Section("Workspace") {
|
||||
ForEach(AppSection.allCases) { section in
|
||||
Button {
|
||||
model.selectedSection = section
|
||||
} label: {
|
||||
HStack {
|
||||
Label(section.title, systemImage: section.systemImage)
|
||||
Spacer()
|
||||
if badgeCount(for: section) > 0 {
|
||||
AppStatusTag(title: "\(badgeCount(for: section))", tone: dashboardAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowBackground(
|
||||
model.selectedSection == section
|
||||
? dashboardAccent.opacity(0.10)
|
||||
: Color.clear
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("idp.global")
|
||||
}
|
||||
|
||||
private func badgeCount(for section: AppSection) -> Int {
|
||||
switch section {
|
||||
case .overview:
|
||||
0
|
||||
case .requests:
|
||||
model.pendingRequests.count
|
||||
case .activity:
|
||||
model.unreadNotificationCount
|
||||
case .account:
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarStatusCard: View {
|
||||
let profile: MemberProfile?
|
||||
let pendingCount: Int
|
||||
let unreadCount: Int
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Digital Passport")
|
||||
.font(.headline)
|
||||
|
||||
Text(profile?.handle ?? "No passport active")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: "\(pendingCount) pending", tone: dashboardAccent)
|
||||
AppStatusTag(title: "\(unreadCount) unread", tone: dashboardGold)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RequestDetailSheet: View {
|
||||
let request: ApprovalRequest
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
AppScrollScreen(
|
||||
compactLayout: true,
|
||||
bottomPadding: AppLayout.compactBottomDockPadding
|
||||
) {
|
||||
RequestDetailHero(request: request)
|
||||
|
||||
AppSectionCard(title: "Summary", compactLayout: true) {
|
||||
AppKeyValue(label: "Source", value: request.source)
|
||||
AppKeyValue(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened))
|
||||
AppKeyValue(label: "Risk", value: request.risk.summary)
|
||||
AppKeyValue(label: "Type", value: request.kind.title)
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Proof details", compactLayout: true) {
|
||||
if request.scopes.isEmpty {
|
||||
Text("No explicit proof details were provided by the mock backend.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text(request.scopes.joined(separator: "\n"))
|
||||
.font(.body.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Guidance", compactLayout: true) {
|
||||
Text(request.trustDetail)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(request.risk.guidance)
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
if request.status == .pending {
|
||||
AppSectionCard(title: "Actions", compactLayout: true) {
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
Task {
|
||||
await model.approve(request)
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
if model.activeRequestID == request.id {
|
||||
ProgressView()
|
||||
} else {
|
||||
Label("Verify identity", systemImage: "checkmark.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(model.activeRequestID == request.id)
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await model.reject(request)
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
Label("Decline", systemImage: "xmark.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(model.activeRequestID == request.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Review Proof")
|
||||
.inlineNavigationTitleOnIOS()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RequestDetailHero: View {
|
||||
let request: ApprovalRequest
|
||||
|
||||
private var accent: Color {
|
||||
switch request.status {
|
||||
case .approved:
|
||||
.green
|
||||
case .rejected:
|
||||
.red
|
||||
case .pending:
|
||||
request.risk == .routine ? dashboardAccent : .orange
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: true, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: request.kind.title, tone: accent)
|
||||
|
||||
Text(request.title)
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.lineLimit(3)
|
||||
|
||||
Text(request.subtitle)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: request.status.title, tone: accent)
|
||||
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OneTimePasscodeSheet: View {
|
||||
let session: AuthSession
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
TimelineView(.periodic(from: .now, by: 1)) { context in
|
||||
let code = OneTimePasscodeGenerator.code(for: session.pairingCode, at: context.date)
|
||||
let secondsRemaining = OneTimePasscodeGenerator.renewalCountdown(at: context.date)
|
||||
|
||||
AppScrollScreen(compactLayout: compactLayout) {
|
||||
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: "One-time passcode", tone: dashboardGold)
|
||||
|
||||
Text("OTP")
|
||||
.font(.system(size: compactLayout ? 32 : 40, weight: .bold, design: .rounded))
|
||||
|
||||
Text("Share this code only with the site or device asking you to prove that it is really you.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(code)
|
||||
.font(.system(size: compactLayout ? 42 : 54, weight: .bold, design: .rounded).monospacedDigit())
|
||||
.tracking(compactLayout ? 4 : 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, compactLayout ? 16 : 20)
|
||||
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: "Renews in \(secondsRemaining)s", tone: dashboardGold)
|
||||
AppStatusTag(title: session.originHost, tone: dashboardAccent)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
AppKeyValue(label: "Client", value: session.deviceName)
|
||||
AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("OTP")
|
||||
.inlineNavigationTitleOnIOS()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user