Build passport-style identity app shell

This commit is contained in:
2026-04-17 22:08:27 +02:00
commit 6936ad5cfe
11 changed files with 4922 additions and 0 deletions

View File

@@ -0,0 +1,238 @@
import Combine
import Foundation
@MainActor
final class AppViewModel: ObservableObject {
@Published var suggestedQRCodePayload = ""
@Published var manualQRCodePayload = ""
@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 isRefreshing = false
@Published var isNotificationCenterPresented = false
@Published var activeRequestID: ApprovalRequest.ID?
@Published var isScannerPresented = false
@Published var bannerMessage: String?
@Published var errorMessage: String?
private var hasBootstrapped = false
private let service: IDPServicing
private let notificationCoordinator: NotificationCoordinating
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(),
launchArguments: [String] = ProcessInfo.processInfo.arguments
) {
self.service = service
self.notificationCoordinator = notificationCoordinator
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
isBootstrapping = true
defer { isBootstrapping = false }
do {
let bootstrap = try await service.bootstrap()
suggestedQRCodePayload = bootstrap.suggestedQRCodePayload
manualQRCodePayload = bootstrap.suggestedQRCodePayload
notificationPermission = await notificationCoordinator.authorizationStatus()
if launchArguments.contains("--mock-auto-pair"),
session == nil {
await signIn(with: bootstrap.suggestedQRCodePayload)
if let preferredLaunchSection {
selectedSection = preferredLaunchSection
}
}
} catch {
errorMessage = "Unable to prepare the app."
}
}
func signInWithManualCode() async {
await signIn(with: manualQRCodePayload)
}
func signInWithSuggestedCode() async {
manualQRCodePayload = suggestedQRCodePayload
await signIn(with: suggestedQRCodePayload)
}
func signIn(with payload: String) async {
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
errorMessage = "Paste or scan a QR payload first."
return
}
isAuthenticating = true
defer { isAuthenticating = false }
do {
let result = try await service.signIn(withQRCode: trimmed)
session = result.session
apply(snapshot: result.snapshot)
notificationPermission = await notificationCoordinator.authorizationStatus()
selectedSection = .overview
bannerMessage = "Paired with \(result.session.deviceName)."
isScannerPresented = false
} catch let error as AppError {
errorMessage = error.errorDescription
} catch {
errorMessage = "Unable to complete sign-in."
}
}
func refreshDashboard() async {
guard session != nil else { return }
isRefreshing = true
defer { isRefreshing = false }
do {
let snapshot = try await service.refreshDashboard()
apply(snapshot: snapshot)
} 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)
selectedSection = .requests
bannerMessage = "A new mock approval request arrived."
} catch {
errorMessage = "Unable to seed a new request right now."
}
}
func requestNotificationAccess() async {
do {
notificationPermission = try await notificationCoordinator.requestAuthorization()
if notificationPermission == .allowed || notificationPermission == .provisional {
bannerMessage = "Notifications are ready on this device."
}
} catch {
errorMessage = "Unable to update notification permission."
}
}
func sendTestNotification() async {
do {
try await notificationCoordinator.scheduleTestNotification(
title: "idp.global approval pending",
body: "A mock request is waiting for approval in the app."
)
bannerMessage = "A local test notification will appear in a few seconds."
notificationPermission = await notificationCoordinator.authorizationStatus()
} 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)
} catch {
errorMessage = "Unable to update the notification."
}
}
func signOut() {
session = nil
profile = nil
requests = []
notifications = []
selectedSection = .overview
bannerMessage = nil
manualQRCodePayload = suggestedQRCodePayload
}
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)
bannerMessage = approve ? "Request approved for \(request.source)." : "Request rejected for \(request.source)."
} catch {
errorMessage = "Unable to update the request."
}
}
private func apply(snapshot: DashboardSnapshot) {
profile = snapshot.profile
requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt }
}
}

View File

@@ -0,0 +1,63 @@
import SwiftUI
@main
struct IDPGlobalApp: App {
@StateObject private var model = AppViewModel()
var body: some Scene {
WindowGroup {
RootView(model: model)
.tint(Color(red: 0.12, green: 0.40, blue: 0.31))
.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(
LinearGradient(
colors: [
Color(red: 0.96, green: 0.97, blue: 0.94),
Color(red: 0.89, green: 0.94, blue: 0.92),
Color(red: 0.94, green: 0.91, blue: 0.84)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
}
}

View File

@@ -0,0 +1,346 @@
import Foundation
enum AppSection: String, CaseIterable, Identifiable, Hashable {
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 {
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:
"Alerts can break through immediately when a request arrives."
case .provisional:
"Notifications can be delivered quietly until the user promotes them."
case .denied:
"Approval events stay in-app until the user re-enables notifications."
}
}
}
struct BootstrapContext {
let suggestedQRCodePayload: String
}
struct DashboardSnapshot {
let profile: MemberProfile
let requests: [ApprovalRequest]
let notifications: [AppNotification]
}
struct SignInResult {
let session: AuthSession
let snapshot: DashboardSnapshot
}
struct MemberProfile: Identifiable, Hashable {
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 {
let id: UUID
let deviceName: String
let originHost: String
let pairedAt: Date
let tokenPreview: String
let pairingCode: String
init(
id: UUID = UUID(),
deviceName: String,
originHost: String,
pairedAt: Date,
tokenPreview: String,
pairingCode: String
) {
self.id = id
self.deviceName = deviceName
self.originHost = originHost
self.pairedAt = pairedAt
self.tokenPreview = tokenPreview
self.pairingCode = pairingCode
}
}
enum ApprovalRequestKind: String, CaseIterable, Hashable {
case signIn
case accessGrant
case elevatedAction
var title: String {
switch self {
case .signIn: "Sign-In"
case .accessGrant: "Access Grant"
case .elevatedAction: "Elevated Action"
}
}
var systemImage: String {
switch self {
case .signIn: "qrcode.viewfinder"
case .accessGrant: "key.fill"
case .elevatedAction: "shield.lefthalf.filled"
}
}
}
enum ApprovalRisk: String, Hashable {
case routine
case elevated
var title: String {
switch self {
case .routine: "Routine"
case .elevated: "Elevated"
}
}
var summary: String {
switch self {
case .routine:
"Routine access to profile or sign-in scopes."
case .elevated:
"Sensitive access that can sign, publish, or unlock privileged actions."
}
}
var guidance: String {
switch self {
case .routine:
"Review the origin and scope list, then approve if the session matches the device you expect."
case .elevated:
"Treat this like a privileged operation. Verify the origin, the requested scopes, and whether the action is time-bound before approving."
}
}
}
enum ApprovalStatus: String, Hashable {
case pending
case approved
case rejected
var title: String {
switch self {
case .pending: "Pending"
case .approved: "Approved"
case .rejected: "Rejected"
}
}
var systemImage: String {
switch self {
case .pending: "clock.badge"
case .approved: "checkmark.circle.fill"
case .rejected: "xmark.circle.fill"
}
}
}
struct ApprovalRequest: Identifiable, Hashable {
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 scopes listed"
}
let suffix = scopes.count == 1 ? "" : "s"
return "\(scopes.count) requested scope\(suffix)"
}
var trustHeadline: String {
switch (kind, risk) {
case (.signIn, .routine):
"Low-friction sign-in request"
case (.signIn, .elevated):
"Privileged sign-in request"
case (.accessGrant, _):
"Token grant request"
case (.elevatedAction, _):
"Sensitive action request"
}
}
var trustDetail: String {
switch kind {
case .signIn:
"This request usually creates or refreshes a session token for a browser, CLI, or device."
case .accessGrant:
"This request issues scoped access for a service or automation that wants to act on your behalf."
case .elevatedAction:
"This request performs a privileged action such as signing, publishing, or creating short-lived credentials."
}
}
}
enum AppNotificationKind: String, Hashable {
case approval
case security
case system
var title: String {
switch self {
case .approval: "Approval"
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:
"Decision and approval activity"
case .security:
"Pairing and security posture updates"
case .system:
"Product and environment status messages"
}
}
}
struct AppNotification: Identifiable, Hashable {
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 {
case invalidQRCode
case requestNotFound
var errorDescription: String? {
switch self {
case .invalidQRCode:
"That QR payload is not valid for idp.global sign-in."
case .requestNotFound:
"The selected request could not be found."
}
}
}

View File

@@ -0,0 +1,246 @@
import Foundation
protocol IDPServicing {
func bootstrap() async throws -> BootstrapContext
func signIn(withQRCode payload: String) async throws -> SignInResult
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(
suggestedQRCodePayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
)
}
func signIn(withQRCode payload: String) async throws -> SignInResult {
try await Task.sleep(for: .milliseconds(260))
let session = try parseSession(from: payload)
notifications.insert(
AppNotification(
title: "New device paired",
message: "\(session.deviceName) completed a QR pairing against \(session.originHost).",
sentAt: .now,
kind: .security,
isUnread: true
),
at: 0
)
return SignInResult(
session: session,
snapshot: 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: "Request approved",
message: "\(requests[index].title) was approved 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: "Request rejected",
message: "\(requests[index].title) was rejected before token issuance.",
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: "Approve SSH certificate issue",
subtitle: "CI runner wants a short-lived signing certificate for a deployment pipeline.",
source: "deploy.idp.global",
createdAt: .now,
kind: .elevatedAction,
risk: .elevated,
scopes: ["sign:ssh", "ttl:10m", "environment:staging"],
status: .pending
)
requests.insert(syntheticRequest, at: 0)
notifications.insert(
AppNotification(
title: "Fresh approval request",
message: "A staging deployment is waiting for your approval.",
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 parseSession(from payload: String) throws -> AuthSession {
if let components = URLComponents(string: payload),
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 AuthSession(
deviceName: device,
originHost: origin,
pairedAt: .now,
tokenPreview: String(token.suffix(6)),
pairingCode: payload
)
}
if payload.contains("token") || payload.contains("pair") {
return AuthSession(
deviceName: "Manual Pairing",
originHost: "code.foss.global",
pairedAt: .now,
tokenPreview: String(payload.suffix(6)),
pairingCode: payload
)
}
throw AppError.invalidQRCode
}
private static func seedRequests() -> [ApprovalRequest] {
[
ApprovalRequest(
title: "Approve Safari sign-in",
subtitle: "A browser session from Berlin wants an SSO token for the portal.",
source: "code.foss.global",
createdAt: .now.addingTimeInterval(-60 * 12),
kind: .signIn,
risk: .routine,
scopes: ["openid", "profile", "groups:read"],
status: .pending
),
ApprovalRequest(
title: "Grant package publish access",
subtitle: "The release bot is asking for a scoped publish token.",
source: "registry.foss.global",
createdAt: .now.addingTimeInterval(-60 * 42),
kind: .accessGrant,
risk: .elevated,
scopes: ["packages:write", "ttl:30m"],
status: .pending
),
ApprovalRequest(
title: "Approve CLI login",
subtitle: "A terminal session completed QR pairing earlier today.",
source: "cli.idp.global",
createdAt: .now.addingTimeInterval(-60 * 180),
kind: .signIn,
risk: .routine,
scopes: ["openid", "profile"],
status: .approved
)
]
}
private static func seedNotifications() -> [AppNotification] {
[
AppNotification(
title: "Two requests are waiting",
message: "The queue includes one routine sign-in and one elevated access grant.",
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: "Quiet hours active on mobile",
message: "Routine notifications will be delivered silently until the morning.",
sentAt: .now.addingTimeInterval(-60 * 220),
kind: .security,
isUnread: false
)
]
}
}

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,300 @@
import SwiftUI
private let loginAccent = Color(red: 0.12, green: 0.40, blue: 0.31)
private let loginGold = Color(red: 0.90, green: 0.79, blue: 0.60)
struct LoginRootView: View {
@ObservedObject var model: AppViewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
ScrollView {
VStack(spacing: compactLayout ? 18 : 24) {
LoginHeroPanel(model: model, compactLayout: compactLayout)
PairingConsoleCard(model: model, compactLayout: compactLayout)
TrustFootprintCard(model: model, compactLayout: compactLayout)
}
.frame(maxWidth: 1040)
.padding(compactLayout ? 18 : 28)
}
.sheet(isPresented: $model.isScannerPresented) {
QRScannerSheet(
seededPayload: model.suggestedQRCodePayload,
onCodeScanned: { payload in
model.manualQRCodePayload = payload
Task {
await model.signIn(with: payload)
}
}
)
}
}
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 {
ZStack(alignment: .bottomLeading) {
RoundedRectangle(cornerRadius: 36, style: .continuous)
.fill(
LinearGradient(
colors: [
Color(red: 0.13, green: 0.22, blue: 0.19),
Color(red: 0.20, green: 0.41, blue: 0.33),
loginGold
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
VStack(alignment: .leading, spacing: compactLayout ? 16 : 18) {
Text("Bind this device to your idp.global account")
.font(.system(size: compactLayout ? 32 : 44, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Scan the pairing QR from your account to turn this device into your approval and notification app.")
.font(compactLayout ? .body : .title3)
.foregroundStyle(.white.opacity(0.88))
if compactLayout {
VStack(alignment: .leading, spacing: 10) {
HeroTag(title: "Account binding")
HeroTag(title: "QR pairing")
HeroTag(title: "iPhone, iPad, Mac")
}
} else {
HStack(spacing: 12) {
HeroTag(title: "Account binding")
HeroTag(title: "QR pairing")
HeroTag(title: "iPhone, iPad, Mac")
}
}
if model.isBootstrapping {
ProgressView("Preparing preview pairing payload…")
.tint(.white)
}
}
.padding(compactLayout ? 22 : 32)
}
.frame(minHeight: compactLayout ? 280 : 320)
}
}
private struct PairingConsoleCard: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
LoginCard(title: "Bind your account", subtitle: "Scan the QR code from your idp.global account or use the preview payload while backend wiring is still in progress.") {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Open your account pairing screen, then scan the QR code here.")
.font(.headline)
Text("If you are testing the preview build without the live backend yet, the seeded payload below will still bind the mock session.")
.foregroundStyle(.secondary)
}
TextEditor(text: $model.manualQRCodePayload)
.font(.body.monospaced())
.scrollContentBackground(.hidden)
.padding(16)
.frame(minHeight: compactLayout ? 130 : 150)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
if model.isAuthenticating {
HStack(spacing: 10) {
ProgressView()
Text("Binding this device to your account…")
.foregroundStyle(.secondary)
}
}
Group {
if compactLayout {
VStack(spacing: 12) {
primaryButtons
secondaryButtons
}
} else {
VStack(spacing: 12) {
HStack(spacing: 12) {
primaryButtons
}
HStack(spacing: 12) {
secondaryButtons
}
}
}
}
}
}
}
@ViewBuilder
private var primaryButtons: some View {
Button {
model.isScannerPresented = true
} label: {
Label("Bind With QR Code", systemImage: "qrcode.viewfinder")
}
.buttonStyle(.borderedProminent)
Button {
Task {
await model.signInWithManualCode()
}
} label: {
if model.isAuthenticating {
ProgressView()
} else {
Label("Bind With Payload", systemImage: "arrow.right.circle.fill")
}
}
.buttonStyle(.bordered)
.disabled(model.isAuthenticating)
}
@ViewBuilder
private var secondaryButtons: some View {
Button {
Task {
await model.signInWithSuggestedCode()
}
} label: {
Label("Use Preview QR", systemImage: "wand.and.stars")
}
.buttonStyle(.bordered)
Text("This preview keeps the account-binding flow realistic while the live API is still being wired in.")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .trailing)
}
}
private struct TrustFootprintCard: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
LoginCard(title: "About this build", subtitle: "Keep the first-run screen simple, but still explain the trust context and preview status clearly.") {
VStack(alignment: .leading, spacing: 16) {
if compactLayout {
VStack(spacing: 12) {
trustFacts
}
} else {
HStack(alignment: .top, spacing: 12) {
trustFacts
}
}
VStack(alignment: .leading, spacing: 8) {
Text("Preview Pairing Payload")
.font(.headline)
Text(model.suggestedQRCodePayload.isEmpty ? "Preparing preview payload…" : model.suggestedQRCodePayload)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
}
}
}
}
@ViewBuilder
private var trustFacts: some View {
TrustFactCard(
icon: "person.badge.key.fill",
title: "Account Binding",
message: "This device binds to your idp.global account and becomes your place for approvals and alerts."
)
TrustFactCard(
icon: "person.2.badge.gearshape.fill",
title: "Built by foss.global",
message: "foss.global is the open-source collective behind idp.global and the current preview environment."
)
TrustFactCard(
icon: "bolt.badge.clock",
title: "Preview Backend",
message: "Login, requests, and notifications are mocked behind a clean service boundary until live integration is ready."
)
}
}
private struct LoginCard<Content: View>: View {
let title: String
let subtitle: String
let content: () -> Content
init(title: String, subtitle: String, @ViewBuilder content: @escaping () -> Content) {
self.title = title
self.subtitle = subtitle
self.content = content
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.title2.weight(.semibold))
Text(subtitle)
.foregroundStyle(.secondary)
}
content()
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white.opacity(0.68), in: RoundedRectangle(cornerRadius: 32, style: .continuous))
}
}
private struct HeroTag: View {
let title: String
var body: some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(.white.opacity(0.14), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
private struct TrustFactCard: View {
let icon: String
let title: String
let message: String
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(loginAccent)
Text(title)
.font(.headline)
Text(message)
.foregroundStyle(.secondary)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
}
}

View File

@@ -0,0 +1,359 @@
import AVFoundation
import Combine
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
struct QRScannerSheet: View {
let seededPayload: String
let onCodeScanned: (String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var manualFallback = ""
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("Use the camera to scan the QR code shown by the web portal. If youre on a simulator or desktop without a camera, the seeded payload works as a mock fallback.")
.foregroundStyle(.secondary)
LiveQRScannerView(onCodeScanned: onCodeScanned)
.frame(minHeight: 340)
VStack(alignment: .leading, spacing: 12) {
Text("Fallback Pairing Payload")
.font(.headline)
TextEditor(text: $manualFallback)
.font(.body.monospaced())
.scrollContentBackground(.hidden)
.padding(14)
.frame(minHeight: 120)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
HStack(spacing: 12) {
Button {
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
onCodeScanned(chosen.isEmpty ? seededPayload : chosen)
dismiss()
} label: {
Label("Use Fallback Payload", systemImage: "arrow.up.forward.square")
}
.buttonStyle(.borderedProminent)
Button {
manualFallback = seededPayload
} label: {
Label("Use Seeded Mock", systemImage: "wand.and.rays")
}
.buttonStyle(.bordered)
}
}
.padding(20)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
}
.padding(24)
}
.navigationTitle("Scan QR Code")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
dismiss()
}
}
}
.onAppear {
manualFallback = seededPayload
}
}
}
}
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),
let input = try? AVCaptureDeviceInput(device: device),
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 {
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)
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

File diff suppressed because it is too large Load Diff