61a0cc1f7d
CI / test (push) Has been cancelled
Bring the SwiftUI app in line with the Apple-native mock and keep pending approvals actionable from Live Activities and watch complications.
347 lines
9.7 KiB
Swift
347 lines
9.7 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 {
|
|
"Berlin, DE"
|
|
}
|
|
|
|
var deviceSummary: String {
|
|
switch kind {
|
|
case .signIn:
|
|
"Safari on Berlin iPhone"
|
|
case .accessGrant:
|
|
"Chrome on iPad Pro"
|
|
case .elevatedAction:
|
|
"Berlin MacBook Pro"
|
|
}
|
|
}
|
|
|
|
var networkSummary: String {
|
|
switch kind {
|
|
case .signIn:
|
|
"Home Wi-Fi"
|
|
case .accessGrant:
|
|
"Shared office Wi-Fi"
|
|
case .elevatedAction:
|
|
"Ethernet"
|
|
}
|
|
}
|
|
|
|
var ipSummary: String {
|
|
risk == .elevated ? "84.187.12.44" : "84.187.12.36"
|
|
}
|
|
|
|
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 {
|
|
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
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
MonogramAvatar(title: request.appDisplayName, size: compact ? 32 : 40)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(request.inboxTitle)
|
|
.font(compact ? .subheadline.weight(.semibold) : .headline)
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(2)
|
|
|
|
Text("as \(handle) · \(request.locationSummary)")
|
|
.font(compact ? .caption : .subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
HStack(spacing: 10) {
|
|
TimeChip(date: request.createdAt, compact: compact)
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.padding(.vertical, compact ? 6 : 10)
|
|
.padding(.horizontal, 12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.fill(highlighted ? IdP.tint.opacity(0.06) : Color.clear)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.stroke(highlighted ? IdP.tint : Color.clear, lineWidth: highlighted ? 1.5 : 0)
|
|
)
|
|
.contentShape(Rectangle())
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityLabel("\(request.inboxTitle), \(request.locationSummary), \(request.createdAt.formatted(date: .omitted, time: .shortened))")
|
|
}
|
|
}
|
|
|
|
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) {
|
|
Image(systemName: device.systemImage)
|
|
.font(.headline)
|
|
.foregroundStyle(IdP.tint)
|
|
.frame(width: 28)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(device.name)
|
|
.font(.body.weight(.medium))
|
|
|
|
Text(device.isCurrent ? "This device" : "Seen \(device.lastSeen, style: .relative)")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
StatusDot(color: device.isTrusted ? .green : .yellow)
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.deviceRowStyle()
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|
|
|
|
struct TrustSignalBanner: View {
|
|
let request: ApprovalRequest
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: symbolName)
|
|
.font(.headline)
|
|
.foregroundStyle(request.trustColor)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(request.trustHeadline)
|
|
.font(.subheadline.weight(.semibold))
|
|
|
|
Text(request.trustExplanation)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
private var symbolName: String {
|
|
switch request.trustColor {
|
|
case .green:
|
|
return "checkmark.shield.fill"
|
|
case .yellow:
|
|
return "exclamationmark.triangle.fill"
|
|
default:
|
|
return "xmark.shield.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|