2026-04-18 01:05:22 +02:00
|
|
|
import CryptoKit
|
2026-04-17 22:08:27 +02:00
|
|
|
import Foundation
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
enum AppSection: String, CaseIterable, Identifiable, Hashable, Codable {
|
2026-04-19 16:29:13 +02:00
|
|
|
case inbox
|
|
|
|
|
case notifications
|
|
|
|
|
case devices
|
|
|
|
|
case identity
|
|
|
|
|
case settings
|
2026-04-17 22:08:27 +02:00
|
|
|
|
|
|
|
|
var id: String { rawValue }
|
|
|
|
|
|
|
|
|
|
var title: String {
|
|
|
|
|
switch self {
|
2026-04-19 16:29:13 +02:00
|
|
|
case .inbox: "Inbox"
|
|
|
|
|
case .notifications: "Notifications"
|
|
|
|
|
case .devices: "Devices"
|
|
|
|
|
case .identity: "Identity"
|
|
|
|
|
case .settings: "Settings"
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var systemImage: String {
|
|
|
|
|
switch self {
|
2026-04-19 16:29:13 +02:00
|
|
|
case .inbox: "tray.full.fill"
|
|
|
|
|
case .notifications: "bell.badge.fill"
|
|
|
|
|
case .devices: "desktopcomputer"
|
|
|
|
|
case .identity: "person.crop.rectangle.stack.fill"
|
|
|
|
|
case .settings: "gearshape.fill"
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
enum NotificationPermissionState: String, CaseIterable, Identifiable, Codable {
|
2026-04-17 22:08:27 +02:00
|
|
|
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:
|
2026-04-18 01:05:22 +02:00
|
|
|
"Identity proof alerts can break through immediately when a check arrives."
|
2026-04-17 22:08:27 +02:00
|
|
|
case .provisional:
|
2026-04-18 01:05:22 +02:00
|
|
|
"Identity proof alerts can be delivered quietly until the user promotes them."
|
2026-04-17 22:08:27 +02:00
|
|
|
case .denied:
|
2026-04-18 01:05:22 +02:00
|
|
|
"Identity proof events stay in-app until the user re-enables notifications."
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct BootstrapContext {
|
2026-04-18 01:05:22 +02:00
|
|
|
let suggestedPairingPayload: String
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
enum PairingTransport: String, Hashable, Codable {
|
2026-04-18 01:05:22 +02:00
|
|
|
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?
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
struct SignedGPSPosition: Hashable, Codable {
|
2026-04-18 01:05:22 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct DashboardSnapshot {
|
|
|
|
|
let profile: MemberProfile
|
|
|
|
|
let requests: [ApprovalRequest]
|
|
|
|
|
let notifications: [AppNotification]
|
2026-04-20 13:21:39 +00:00
|
|
|
let devices: [PassportDeviceRecord]
|
|
|
|
|
|
|
|
|
|
init(
|
|
|
|
|
profile: MemberProfile,
|
|
|
|
|
requests: [ApprovalRequest],
|
|
|
|
|
notifications: [AppNotification],
|
|
|
|
|
devices: [PassportDeviceRecord] = []
|
|
|
|
|
) {
|
|
|
|
|
self.profile = profile
|
|
|
|
|
self.requests = requests
|
|
|
|
|
self.notifications = notifications
|
|
|
|
|
self.devices = devices
|
|
|
|
|
}
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct SignInResult {
|
|
|
|
|
let session: AuthSession
|
|
|
|
|
let snapshot: DashboardSnapshot
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
struct MemberProfile: Identifiable, Hashable, Codable {
|
2026-04-17 22:08:27 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
struct AuthSession: Identifiable, Hashable, Codable {
|
2026-04-17 22:08:27 +02:00
|
|
|
let id: UUID
|
|
|
|
|
let deviceName: String
|
|
|
|
|
let originHost: String
|
2026-04-20 13:21:39 +00:00
|
|
|
let serverURL: String
|
|
|
|
|
let passportDeviceID: String
|
2026-04-17 22:08:27 +02:00
|
|
|
let pairedAt: Date
|
|
|
|
|
let tokenPreview: String
|
|
|
|
|
let pairingCode: String
|
2026-04-18 01:05:22 +02:00
|
|
|
let pairingTransport: PairingTransport
|
|
|
|
|
let signedGPSPosition: SignedGPSPosition?
|
2026-04-17 22:08:27 +02:00
|
|
|
|
|
|
|
|
init(
|
|
|
|
|
id: UUID = UUID(),
|
|
|
|
|
deviceName: String,
|
|
|
|
|
originHost: String,
|
2026-04-20 13:21:39 +00:00
|
|
|
serverURL: String = "",
|
|
|
|
|
passportDeviceID: String = "",
|
2026-04-17 22:08:27 +02:00
|
|
|
pairedAt: Date,
|
|
|
|
|
tokenPreview: String,
|
2026-04-18 01:05:22 +02:00
|
|
|
pairingCode: String,
|
|
|
|
|
pairingTransport: PairingTransport = .manual,
|
|
|
|
|
signedGPSPosition: SignedGPSPosition? = nil
|
2026-04-17 22:08:27 +02:00
|
|
|
) {
|
|
|
|
|
self.id = id
|
|
|
|
|
self.deviceName = deviceName
|
|
|
|
|
self.originHost = originHost
|
2026-04-20 13:21:39 +00:00
|
|
|
self.serverURL = serverURL
|
|
|
|
|
self.passportDeviceID = passportDeviceID
|
2026-04-17 22:08:27 +02:00
|
|
|
self.pairedAt = pairedAt
|
|
|
|
|
self.tokenPreview = tokenPreview
|
|
|
|
|
self.pairingCode = pairingCode
|
2026-04-18 01:05:22 +02:00
|
|
|
self.pairingTransport = pairingTransport
|
|
|
|
|
self.signedGPSPosition = signedGPSPosition
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
2026-04-20 13:21:39 +00:00
|
|
|
|
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
|
|
|
case id
|
|
|
|
|
case deviceName
|
|
|
|
|
case originHost
|
|
|
|
|
case serverURL
|
|
|
|
|
case passportDeviceID
|
|
|
|
|
case pairedAt
|
|
|
|
|
case tokenPreview
|
|
|
|
|
case pairingCode
|
|
|
|
|
case pairingTransport
|
|
|
|
|
case signedGPSPosition
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
init(from decoder: Decoder) throws {
|
|
|
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
|
|
|
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
|
|
|
|
|
deviceName = try container.decode(String.self, forKey: .deviceName)
|
|
|
|
|
originHost = try container.decode(String.self, forKey: .originHost)
|
|
|
|
|
serverURL = try container.decodeIfPresent(String.self, forKey: .serverURL) ?? "https://\(originHost)"
|
|
|
|
|
passportDeviceID = try container.decodeIfPresent(String.self, forKey: .passportDeviceID) ?? ""
|
|
|
|
|
pairedAt = try container.decode(Date.self, forKey: .pairedAt)
|
|
|
|
|
tokenPreview = try container.decode(String.self, forKey: .tokenPreview)
|
|
|
|
|
pairingCode = try container.decode(String.self, forKey: .pairingCode)
|
|
|
|
|
pairingTransport = try container.decodeIfPresent(PairingTransport.self, forKey: .pairingTransport) ?? .manual
|
|
|
|
|
signedGPSPosition = try container.decodeIfPresent(SignedGPSPosition.self, forKey: .signedGPSPosition)
|
|
|
|
|
}
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
enum ApprovalRequestKind: String, CaseIterable, Hashable, Codable {
|
2026-04-17 22:08:27 +02:00
|
|
|
case signIn
|
|
|
|
|
case accessGrant
|
|
|
|
|
case elevatedAction
|
|
|
|
|
|
|
|
|
|
var title: String {
|
|
|
|
|
switch self {
|
2026-04-18 01:05:22 +02:00
|
|
|
case .signIn: "Identity Check"
|
|
|
|
|
case .accessGrant: "Strong Proof"
|
|
|
|
|
case .elevatedAction: "Sensitive Proof"
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var systemImage: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case .signIn: "qrcode.viewfinder"
|
2026-04-18 01:05:22 +02:00
|
|
|
case .accessGrant: "person.badge.shield.checkmark.fill"
|
|
|
|
|
case .elevatedAction: "shield.checkered"
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
enum ApprovalRisk: String, Hashable, Codable {
|
2026-04-17 22:08:27 +02:00
|
|
|
case routine
|
|
|
|
|
case elevated
|
|
|
|
|
|
|
|
|
|
var title: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case .routine: "Routine"
|
|
|
|
|
case .elevated: "Elevated"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var summary: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case .routine:
|
2026-04-18 01:05:22 +02:00
|
|
|
"A familiar identity proof for a normal sign-in or check."
|
2026-04-17 22:08:27 +02:00
|
|
|
case .elevated:
|
2026-04-18 01:05:22 +02:00
|
|
|
"A higher-assurance identity proof for a sensitive check."
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var guidance: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case .routine:
|
2026-04-18 01:05:22 +02:00
|
|
|
"Review the origin and continue only if it matches the proof you started."
|
2026-04-17 22:08:27 +02:00
|
|
|
case .elevated:
|
2026-04-18 01:05:22 +02:00
|
|
|
"Only continue if you initiated this proof and trust the origin asking for it."
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
enum ApprovalStatus: String, Hashable, Codable {
|
2026-04-17 22:08:27 +02:00
|
|
|
case pending
|
|
|
|
|
case approved
|
|
|
|
|
case rejected
|
|
|
|
|
|
|
|
|
|
var title: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case .pending: "Pending"
|
2026-04-19 16:29:13 +02:00
|
|
|
case .approved: "Approved"
|
|
|
|
|
case .rejected: "Denied"
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var systemImage: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case .pending: "clock.badge"
|
|
|
|
|
case .approved: "checkmark.circle.fill"
|
|
|
|
|
case .rejected: "xmark.circle.fill"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
struct ApprovalRequest: Identifiable, Hashable, Codable {
|
2026-04-17 22:08:27 +02:00
|
|
|
let id: UUID
|
2026-04-20 13:21:39 +00:00
|
|
|
let serverID: String
|
|
|
|
|
let hintID: String
|
2026-04-17 22:08:27 +02:00
|
|
|
let title: String
|
|
|
|
|
let subtitle: String
|
|
|
|
|
let source: String
|
|
|
|
|
let createdAt: Date
|
|
|
|
|
let kind: ApprovalRequestKind
|
|
|
|
|
let risk: ApprovalRisk
|
|
|
|
|
let scopes: [String]
|
2026-04-20 13:21:39 +00:00
|
|
|
let requiresLocation: Bool
|
|
|
|
|
let challenge: String?
|
|
|
|
|
let signingPayload: String?
|
|
|
|
|
let deviceSummaryText: String?
|
|
|
|
|
let locationSummaryText: String?
|
|
|
|
|
let networkSummaryText: String?
|
|
|
|
|
let ipSummaryText: String?
|
|
|
|
|
let expiresAtDate: Date?
|
2026-04-17 22:08:27 +02:00
|
|
|
var status: ApprovalStatus
|
|
|
|
|
|
|
|
|
|
init(
|
|
|
|
|
id: UUID = UUID(),
|
2026-04-20 13:21:39 +00:00
|
|
|
serverID: String = UUID().uuidString,
|
|
|
|
|
hintID: String = UUID().uuidString,
|
2026-04-17 22:08:27 +02:00
|
|
|
title: String,
|
|
|
|
|
subtitle: String,
|
|
|
|
|
source: String,
|
|
|
|
|
createdAt: Date,
|
|
|
|
|
kind: ApprovalRequestKind,
|
|
|
|
|
risk: ApprovalRisk,
|
|
|
|
|
scopes: [String],
|
2026-04-20 13:21:39 +00:00
|
|
|
requiresLocation: Bool = false,
|
|
|
|
|
challenge: String? = nil,
|
|
|
|
|
signingPayload: String? = nil,
|
|
|
|
|
deviceSummaryText: String? = nil,
|
|
|
|
|
locationSummaryText: String? = nil,
|
|
|
|
|
networkSummaryText: String? = nil,
|
|
|
|
|
ipSummaryText: String? = nil,
|
|
|
|
|
expiresAtDate: Date? = nil,
|
2026-04-17 22:08:27 +02:00
|
|
|
status: ApprovalStatus
|
|
|
|
|
) {
|
|
|
|
|
self.id = id
|
2026-04-20 13:21:39 +00:00
|
|
|
self.serverID = serverID
|
|
|
|
|
self.hintID = hintID
|
2026-04-17 22:08:27 +02:00
|
|
|
self.title = title
|
|
|
|
|
self.subtitle = subtitle
|
|
|
|
|
self.source = source
|
|
|
|
|
self.createdAt = createdAt
|
|
|
|
|
self.kind = kind
|
|
|
|
|
self.risk = risk
|
|
|
|
|
self.scopes = scopes
|
2026-04-20 13:21:39 +00:00
|
|
|
self.requiresLocation = requiresLocation
|
|
|
|
|
self.challenge = challenge
|
|
|
|
|
self.signingPayload = signingPayload
|
|
|
|
|
self.deviceSummaryText = deviceSummaryText
|
|
|
|
|
self.locationSummaryText = locationSummaryText
|
|
|
|
|
self.networkSummaryText = networkSummaryText
|
|
|
|
|
self.ipSummaryText = ipSummaryText
|
|
|
|
|
self.expiresAtDate = expiresAtDate
|
2026-04-17 22:08:27 +02:00
|
|
|
self.status = status
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 13:21:39 +00:00
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
|
|
|
case id
|
|
|
|
|
case serverID
|
|
|
|
|
case hintID
|
|
|
|
|
case title
|
|
|
|
|
case subtitle
|
|
|
|
|
case source
|
|
|
|
|
case createdAt
|
|
|
|
|
case kind
|
|
|
|
|
case risk
|
|
|
|
|
case scopes
|
|
|
|
|
case requiresLocation
|
|
|
|
|
case challenge
|
|
|
|
|
case signingPayload
|
|
|
|
|
case deviceSummaryText
|
|
|
|
|
case locationSummaryText
|
|
|
|
|
case networkSummaryText
|
|
|
|
|
case ipSummaryText
|
|
|
|
|
case expiresAtDate
|
|
|
|
|
case status
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
init(from decoder: Decoder) throws {
|
|
|
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
|
|
|
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
|
|
|
|
|
serverID = try container.decodeIfPresent(String.self, forKey: .serverID) ?? id.uuidString
|
|
|
|
|
hintID = try container.decodeIfPresent(String.self, forKey: .hintID) ?? serverID
|
|
|
|
|
title = try container.decode(String.self, forKey: .title)
|
|
|
|
|
subtitle = try container.decode(String.self, forKey: .subtitle)
|
|
|
|
|
source = try container.decode(String.self, forKey: .source)
|
|
|
|
|
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
|
|
|
|
kind = try container.decode(ApprovalRequestKind.self, forKey: .kind)
|
|
|
|
|
risk = try container.decode(ApprovalRisk.self, forKey: .risk)
|
|
|
|
|
scopes = try container.decode([String].self, forKey: .scopes)
|
|
|
|
|
requiresLocation = try container.decodeIfPresent(Bool.self, forKey: .requiresLocation) ?? false
|
|
|
|
|
challenge = try container.decodeIfPresent(String.self, forKey: .challenge)
|
|
|
|
|
signingPayload = try container.decodeIfPresent(String.self, forKey: .signingPayload)
|
|
|
|
|
deviceSummaryText = try container.decodeIfPresent(String.self, forKey: .deviceSummaryText)
|
|
|
|
|
locationSummaryText = try container.decodeIfPresent(String.self, forKey: .locationSummaryText)
|
|
|
|
|
networkSummaryText = try container.decodeIfPresent(String.self, forKey: .networkSummaryText)
|
|
|
|
|
ipSummaryText = try container.decodeIfPresent(String.self, forKey: .ipSummaryText)
|
|
|
|
|
expiresAtDate = try container.decodeIfPresent(Date.self, forKey: .expiresAtDate)
|
|
|
|
|
status = try container.decode(ApprovalStatus.self, forKey: .status)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 22:08:27 +02:00
|
|
|
var scopeSummary: String {
|
|
|
|
|
if scopes.isEmpty {
|
2026-04-18 01:05:22 +02:00
|
|
|
return "No proof details listed"
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let suffix = scopes.count == 1 ? "" : "s"
|
2026-04-18 01:05:22 +02:00
|
|
|
return "\(scopes.count) proof detail\(suffix)"
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var trustHeadline: String {
|
|
|
|
|
switch (kind, risk) {
|
|
|
|
|
case (.signIn, .routine):
|
2026-04-18 01:05:22 +02:00
|
|
|
"Standard identity proof"
|
2026-04-17 22:08:27 +02:00
|
|
|
case (.signIn, .elevated):
|
2026-04-18 01:05:22 +02:00
|
|
|
"High-assurance sign-in proof"
|
2026-04-17 22:08:27 +02:00
|
|
|
case (.accessGrant, _):
|
2026-04-18 01:05:22 +02:00
|
|
|
"Cross-device identity proof"
|
2026-04-17 22:08:27 +02:00
|
|
|
case (.elevatedAction, _):
|
2026-04-18 01:05:22 +02:00
|
|
|
"Sensitive identity proof"
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var trustDetail: String {
|
|
|
|
|
switch kind {
|
|
|
|
|
case .signIn:
|
2026-04-18 01:05:22 +02:00
|
|
|
"This request proves that the person at the browser, CLI, or device is really you."
|
2026-04-17 22:08:27 +02:00
|
|
|
case .accessGrant:
|
2026-04-18 01:05:22 +02:00
|
|
|
"This request asks for a stronger proof so the relying party can trust the session with higher confidence."
|
2026-04-17 22:08:27 +02:00
|
|
|
case .elevatedAction:
|
2026-04-18 01:05:22 +02:00
|
|
|
"This request asks for the highest confidence proof before continuing with a sensitive flow."
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
enum AppNotificationKind: String, Hashable, Codable {
|
2026-04-17 22:08:27 +02:00
|
|
|
case approval
|
|
|
|
|
case security
|
|
|
|
|
case system
|
|
|
|
|
|
|
|
|
|
var title: String {
|
|
|
|
|
switch self {
|
2026-04-18 01:05:22 +02:00
|
|
|
case .approval: "Proof"
|
2026-04-17 22:08:27 +02:00
|
|
|
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:
|
2026-04-18 01:05:22 +02:00
|
|
|
"Identity proof activity"
|
2026-04-17 22:08:27 +02:00
|
|
|
case .security:
|
2026-04-18 01:05:22 +02:00
|
|
|
"Passport and security posture updates"
|
2026-04-17 22:08:27 +02:00
|
|
|
case .system:
|
|
|
|
|
"Product and environment status messages"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
struct AppNotification: Identifiable, Hashable, Codable {
|
2026-04-17 22:08:27 +02:00
|
|
|
let id: UUID
|
2026-04-20 13:21:39 +00:00
|
|
|
let serverID: String
|
|
|
|
|
let hintID: String
|
2026-04-17 22:08:27 +02:00
|
|
|
let title: String
|
|
|
|
|
let message: String
|
|
|
|
|
let sentAt: Date
|
|
|
|
|
let kind: AppNotificationKind
|
|
|
|
|
var isUnread: Bool
|
|
|
|
|
|
|
|
|
|
init(
|
|
|
|
|
id: UUID = UUID(),
|
2026-04-20 13:21:39 +00:00
|
|
|
serverID: String = UUID().uuidString,
|
|
|
|
|
hintID: String = UUID().uuidString,
|
2026-04-17 22:08:27 +02:00
|
|
|
title: String,
|
|
|
|
|
message: String,
|
|
|
|
|
sentAt: Date,
|
|
|
|
|
kind: AppNotificationKind,
|
|
|
|
|
isUnread: Bool
|
|
|
|
|
) {
|
|
|
|
|
self.id = id
|
2026-04-20 13:21:39 +00:00
|
|
|
self.serverID = serverID
|
|
|
|
|
self.hintID = hintID
|
2026-04-17 22:08:27 +02:00
|
|
|
self.title = title
|
|
|
|
|
self.message = message
|
|
|
|
|
self.sentAt = sentAt
|
|
|
|
|
self.kind = kind
|
|
|
|
|
self.isUnread = isUnread
|
|
|
|
|
}
|
2026-04-20 13:21:39 +00:00
|
|
|
|
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
|
|
|
case id
|
|
|
|
|
case serverID
|
|
|
|
|
case hintID
|
|
|
|
|
case title
|
|
|
|
|
case message
|
|
|
|
|
case sentAt
|
|
|
|
|
case kind
|
|
|
|
|
case isUnread
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
init(from decoder: Decoder) throws {
|
|
|
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
|
|
|
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
|
|
|
|
|
serverID = try container.decodeIfPresent(String.self, forKey: .serverID) ?? id.uuidString
|
|
|
|
|
hintID = try container.decodeIfPresent(String.self, forKey: .hintID) ?? serverID
|
|
|
|
|
title = try container.decode(String.self, forKey: .title)
|
|
|
|
|
message = try container.decode(String.self, forKey: .message)
|
|
|
|
|
sentAt = try container.decode(Date.self, forKey: .sentAt)
|
|
|
|
|
kind = try container.decode(AppNotificationKind.self, forKey: .kind)
|
|
|
|
|
isUnread = try container.decode(Bool.self, forKey: .isUnread)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct PassportDeviceRecord: Identifiable, Hashable, Codable {
|
|
|
|
|
let id: String
|
|
|
|
|
let label: String
|
|
|
|
|
let platform: String
|
|
|
|
|
let lastSeenAt: Date?
|
|
|
|
|
let isCurrent: Bool
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
enum AppError: LocalizedError, Equatable {
|
2026-04-18 01:05:22 +02:00
|
|
|
case invalidPairingPayload
|
|
|
|
|
case missingSignedGPSPosition
|
|
|
|
|
case invalidSignedGPSPosition
|
|
|
|
|
case locationPermissionDenied
|
|
|
|
|
case locationUnavailable
|
2026-04-17 22:08:27 +02:00
|
|
|
case requestNotFound
|
|
|
|
|
|
|
|
|
|
var errorDescription: String? {
|
|
|
|
|
switch self {
|
2026-04-18 01:05:22 +02:00
|
|
|
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."
|
2026-04-17 22:08:27 +02:00
|
|
|
case .requestNotFound:
|
2026-04-18 01:05:22 +02:00
|
|
|
"The selected identity check could not be found."
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|