Build passport-style identity app shell
This commit is contained in:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user