Files
swiftapp/swift/Sources/Features/Home/HomeCards.swift
T
jkunz a298b5e421
CI / test (push) Has been cancelled
replace mock passport flows with live server integration
Switch the app to the real passport enrollment, dashboard, device, alert, and challenge APIs so it can pair with idp.global and act on server-backed state instead of demo data.
2026-04-20 13:21:39 +00:00

468 lines
14 KiB
Swift

import SwiftUI
extension ApprovalRequest {
var appDisplayName: String {
source
.replacingOccurrences(of: "auth.", with: "")
.replacingOccurrences(of: ".idp.global", with: ".idp.global")
}
var inboxTitle: String {
"Sign in to \(appDisplayName)"
}
var locationSummary: String {
locationSummaryText ?? "Location not required"
}
var deviceSummary: String {
deviceSummaryText ?? "Trusted passport device"
}
var networkSummary: String {
networkSummaryText ?? source
}
var ipSummary: String {
ipSummaryText ?? "n/a"
}
var trustColor: Color {
switch (status, risk) {
case (.rejected, _):
.red
case (.approved, _), (_, .routine):
.green
case (.pending, .elevated):
.yellow
}
}
var trustExplanation: String {
switch (status, risk) {
case (.approved, _):
"This proof came from a signed device session that matches your usual sign-in pattern."
case (.rejected, _):
"This request was denied, so no data will be shared unless a new sign-in is started."
case (.pending, .routine):
"The origin and device pattern look familiar for this account."
case (.pending, .elevated):
"The request is valid, but it is asking for a stronger proof than usual."
}
}
var expiresAt: Date {
expiresAtDate ?? createdAt.addingTimeInterval(risk == .elevated ? 180 : 300)
}
}
private enum NotificationPresentationStatus {
case approved
case denied
case expired
var title: String {
switch self {
case .approved: "Approved"
case .denied: "Denied"
case .expired: "Expired"
}
}
var color: Color {
switch self {
case .approved: .green
case .denied: .red
case .expired: .secondary
}
}
}
extension AppNotification {
fileprivate var presentationStatus: NotificationPresentationStatus {
let haystack = "\(title) \(message)".lowercased()
if haystack.contains("declined") || haystack.contains("denied") {
return .denied
}
if haystack.contains("expired") || haystack.contains("quiet hours") {
return .expired
}
return .approved
}
}
struct StatusPill: View {
let title: String
let color: Color
var body: some View {
Text(title)
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(color.opacity(0.12), in: Capsule(style: .continuous))
.foregroundStyle(color)
}
}
struct TimeChip: View {
let date: Date
var compact = false
var body: some View {
Text(date, format: .dateTime.hour().minute())
.font(compact ? .caption2.weight(.medium) : .caption.weight(.medium))
.monospacedDigit()
.padding(.horizontal, compact ? 8 : 10)
.padding(.vertical, compact ? 4 : 6)
.background(Color.idpTertiaryFill, in: Capsule(style: .continuous))
.foregroundStyle(.secondary)
}
}
struct ApprovalRow: View {
let request: ApprovalRequest
let handle: String
var compact = false
var highlighted = false
/// When non-nil the row renders inline Deny/Approve buttons below the
/// header matching the shadcn inbox card.
var onApprove: (() -> Void)? = nil
var onDeny: (() -> Void)? = nil
var isBusy = false
private var showsInlineActions: Bool {
request.status == .pending && onApprove != nil && onDeny != nil && !compact
}
private var tint: Color { BrandTint.color(for: request.appDisplayName) }
var body: some View {
VStack(alignment: .leading, spacing: showsInlineActions ? 12 : 0) {
header
if showsInlineActions {
actionRow
}
}
.padding(showsInlineActions ? 14 : (compact ? 10 : 12))
.background(background)
.overlay(stroke)
.background(glow)
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
.accessibilityLabel("\(request.inboxTitle), \(request.locationSummary), \(request.createdAt.formatted(date: .omitted, time: .shortened))")
}
private var header: some View {
HStack(alignment: .top, spacing: 12) {
MonogramAvatar(
title: request.appDisplayName,
size: compact ? 32 : 40,
tint: tint,
filled: true
)
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(request.appDisplayName)
.font(compact ? .footnote.weight(.semibold) : .subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
Spacer(minLength: 4)
Text(relativeTime)
.font(.caption)
.foregroundStyle(Color.idpMutedForeground)
}
Text(request.kind.title)
.font(compact ? .caption : .footnote)
.foregroundStyle(Color.idpMutedForeground)
.lineLimit(1)
if !compact {
HStack(spacing: 8) {
Label {
Text(request.locationSummary)
} icon: {
Image(systemName: "location.fill")
}
.font(.caption)
.foregroundStyle(Color.idpMutedForeground)
.labelStyle(.titleAndIcon)
ShadcnBadge(
title: riskBadgeTitle,
tone: riskBadgeTone
)
}
.padding(.top, 6)
}
}
if !showsInlineActions && compact {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.idpMutedForeground)
.padding(.top, 4)
}
}
}
private var actionRow: some View {
HStack(spacing: 8) {
Button(action: { onDeny?() }) {
Text("Deny")
}
.buttonStyle(SecondaryActionStyle())
.disabled(isBusy)
Button(action: { onApprove?() }) {
if isBusy {
ProgressView().tint(Color.idpPrimaryForeground)
} else {
Label("Approve", systemImage: "checkmark")
.labelStyle(.titleAndIcon)
}
}
.buttonStyle(PrimaryActionStyle())
.disabled(isBusy)
}
}
private var background: some View {
RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)
.fill(Color.idpCard)
}
private var stroke: some View {
RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)
.stroke(highlighted ? IdP.tint : Color.idpBorder, lineWidth: 1)
}
@ViewBuilder
private var glow: some View {
if highlighted {
RoundedRectangle(cornerRadius: IdP.cardRadius + 3, style: .continuous)
.fill(IdP.tint.opacity(0.10))
.padding(-3)
}
}
private var relativeTime: String {
let seconds = Int(Date.now.timeIntervalSince(request.createdAt))
if seconds < 60 { return "now" }
if seconds < 3600 { return "\(seconds / 60)m" }
if seconds < 86_400 { return "\(seconds / 3600)h" }
return "\(seconds / 86_400)d"
}
private var riskBadgeTitle: String {
switch (request.status, request.risk) {
case (.approved, _): return "approved"
case (.rejected, _): return "denied"
case (.pending, .routine): return "trusted"
case (.pending, .elevated): return "new network"
}
}
private var riskBadgeTone: ShadcnBadge.Tone {
switch (request.status, request.risk) {
case (.approved, _): return .ok
case (.rejected, _): return .danger
case (.pending, .routine): return .ok
case (.pending, .elevated): return .warn
}
}
}
struct NotificationEventRow: View {
let notification: AppNotification
var body: some View {
HStack(alignment: .top, spacing: 12) {
MonogramAvatar(title: notification.title, size: 40, tint: notification.presentationStatus.color)
VStack(alignment: .leading, spacing: 5) {
Text(notification.title)
.font(.headline)
.lineLimit(2)
Text(notification.message)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
StatusPill(title: notification.presentationStatus.title, color: notification.presentationStatus.color)
}
.padding(.vertical, 8)
.accessibilityElement(children: .combine)
}
}
struct NotificationPermissionCard: View {
@ObservedObject var model: AppViewModel
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Label("Allow sign-in alerts", systemImage: model.notificationPermission.systemImage)
.font(.headline)
Text(model.notificationPermission.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
VStack(spacing: 10) {
Button("Enable Notifications") {
Task {
await model.requestNotificationAccess()
}
}
.buttonStyle(PrimaryActionStyle())
Button("Send Test Alert") {
Task {
await model.sendTestNotification()
}
}
.buttonStyle(SecondaryActionStyle())
}
}
.approvalCard()
}
}
struct DevicePresentation: Identifiable, Hashable {
let id: UUID
let name: String
let systemImage: String
let lastSeen: Date
let isCurrent: Bool
let isTrusted: Bool
init(
id: UUID = UUID(),
name: String,
systemImage: String,
lastSeen: Date,
isCurrent: Bool,
isTrusted: Bool
) {
self.id = id
self.name = name
self.systemImage = systemImage
self.lastSeen = lastSeen
self.isCurrent = isCurrent
self.isTrusted = isTrusted
}
}
struct DeviceItemRow: View {
let device: DevicePresentation
var body: some View {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color.idpMuted)
Image(systemName: device.systemImage)
.font(.footnote.weight(.semibold))
.foregroundStyle(.primary)
}
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 1) {
Text(device.name)
.font(.footnote.weight(.semibold))
Text(device.isCurrent ? "berlin · primary · this device" : "last seen \(device.lastSeen, style: .relative)")
.font(.caption)
.foregroundStyle(Color.idpMutedForeground)
}
Spacer(minLength: 8)
ShadcnBadge(
title: device.isTrusted ? "high" : "med",
tone: device.isTrusted ? .ok : .warn
)
}
.deviceRowStyle()
.accessibilityElement(children: .combine)
}
}
struct TrustSignalBanner: View {
let request: ApprovalRequest
private var bg: Color {
switch request.trustColor {
case .green: return Color.idpOK.opacity(0.10)
case .yellow: return Color.idpWarn.opacity(0.15)
default: return Color.idpDestructive.opacity(0.10)
}
}
private var fg: Color {
switch request.trustColor {
case .green: return Color.idpOK
case .yellow: return Color(red: 0.52, green: 0.30, blue: 0.05)
default: return Color.idpDestructive
}
}
private var symbolName: String {
switch request.trustColor {
case .green: return "checkmark.shield"
case .yellow: return "exclamationmark.triangle"
default: return "xmark.shield"
}
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: symbolName)
.font(.subheadline.weight(.semibold))
.foregroundStyle(fg)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 2) {
Text(request.trustHeadline)
.font(.footnote.weight(.semibold))
.foregroundStyle(fg)
Text(request.trustExplanation)
.font(.caption)
.foregroundStyle(Color.idpMutedForeground)
}
Spacer(minLength: 0)
}
.padding(12)
.background(bg, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)
.stroke(Color.idpBorder, lineWidth: 1)
)
}
}
struct EmptyPaneView: View {
let title: String
let message: String
let systemImage: String
var body: some View {
ContentUnavailableView {
Label(title, systemImage: systemImage)
} description: {
Text(message)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}