a298b5e421
CI / test (push) Has been cancelled
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.
468 lines
14 KiB
Swift
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)
|
|
}
|
|
}
|