271d9657bf
CI / test (push) Has been cancelled
Tighten the inbox, detail, and watch layouts so approval actions feel denser and more direct across compact surfaces.
482 lines
14 KiB
Swift
482 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 {
|
|
"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
|
|
/// 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)
|
|
}
|
|
}
|