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.
529 lines
18 KiB
Swift
529 lines
18 KiB
Swift
import SwiftUI
|
|
|
|
struct InboxListView: View {
|
|
@ObservedObject var model: AppViewModel
|
|
@Binding var selectedRequestID: ApprovalRequest.ID?
|
|
@Binding var searchText: String
|
|
@Binding var isSearchPresented: Bool
|
|
var usesSelection = false
|
|
|
|
private var filteredRequests: [ApprovalRequest] {
|
|
guard !searchText.isEmpty else {
|
|
return model.requests
|
|
}
|
|
|
|
return model.requests.filter {
|
|
$0.inboxTitle.localizedCaseInsensitiveContains(searchText)
|
|
|| $0.source.localizedCaseInsensitiveContains(searchText)
|
|
|| $0.subtitle.localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
}
|
|
|
|
private var recentRequests: [ApprovalRequest] {
|
|
filteredRequests.filter { Date.now.timeIntervalSince($0.createdAt) <= 60 * 30 }
|
|
}
|
|
|
|
private var earlierRequests: [ApprovalRequest] {
|
|
filteredRequests.filter { Date.now.timeIntervalSince($0.createdAt) > 60 * 30 }
|
|
}
|
|
|
|
private var highlightedRequestID: ApprovalRequest.ID? {
|
|
filteredRequests.first?.id
|
|
}
|
|
|
|
private var oldestPendingMinutes: Int? {
|
|
guard let oldest = model.pendingRequests.min(by: { $0.createdAt < $1.createdAt }) else {
|
|
return nil
|
|
}
|
|
return max(1, Int(Date.now.timeIntervalSince(oldest.createdAt)) / 60)
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 10, pinnedViews: []) {
|
|
InboxHeader(
|
|
pendingCount: model.pendingRequests.count,
|
|
oldestMinutes: oldestPendingMinutes
|
|
)
|
|
.padding(.horizontal, 4)
|
|
.padding(.bottom, 6)
|
|
|
|
if filteredRequests.isEmpty {
|
|
EmptyPaneView(
|
|
title: "No sign-in requests",
|
|
message: "New approval requests will appear here as soon as a relying party asks for proof.",
|
|
systemImage: "tray"
|
|
)
|
|
.padding(.top, 40)
|
|
} else {
|
|
ForEach(recentRequests) { request in
|
|
row(for: request, compact: false)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
}
|
|
|
|
if !earlierRequests.isEmpty {
|
|
Text("Earlier today")
|
|
.font(.caption2.weight(.semibold))
|
|
.tracking(0.5)
|
|
.textCase(.uppercase)
|
|
.foregroundStyle(Color.idpMutedForeground)
|
|
.padding(.top, 12)
|
|
.padding(.leading, 4)
|
|
|
|
ForEach(earlierRequests) { request in
|
|
row(for: request, compact: true)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 120)
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
.background(Color.idpBackground.ignoresSafeArea())
|
|
.navigationTitle("Inbox")
|
|
.animation(.spring(response: 0.35, dampingFraction: 0.88), value: filteredRequests.map(\.id))
|
|
#if !os(macOS)
|
|
.searchable(text: $searchText, isPresented: $isSearchPresented, placement: .navigationBarDrawer(displayMode: .automatic))
|
|
#else
|
|
.searchable(text: $searchText, isPresented: $isSearchPresented)
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func row(for request: ApprovalRequest, compact: Bool) -> some View {
|
|
let handle = model.profile?.handle ?? "@you"
|
|
let highlighted = highlightedRequestID == request.id
|
|
let isBusy = model.activeRequestID == request.id
|
|
let rowContent = ApprovalRow(
|
|
request: request,
|
|
handle: handle,
|
|
compact: compact,
|
|
highlighted: highlighted,
|
|
onApprove: compact ? nil : { Task { await model.approve(request) } },
|
|
onDeny: compact ? nil : {
|
|
Haptics.warning()
|
|
Task { await model.reject(request) }
|
|
},
|
|
isBusy: isBusy
|
|
)
|
|
|
|
if usesSelection {
|
|
Button {
|
|
selectedRequestID = request.id
|
|
Haptics.selection()
|
|
} label: {
|
|
rowContent
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
NavigationLink(value: request.id) {
|
|
rowContent
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct InboxHeader: View {
|
|
let pendingCount: Int
|
|
let oldestMinutes: Int?
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
|
.fill(Color.idpPrimary)
|
|
Image(systemName: "shield.lefthalf.filled")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(Color.idpPrimaryForeground)
|
|
}
|
|
.frame(width: 24, height: 24)
|
|
|
|
Text("idp.global")
|
|
.font(.subheadline.weight(.semibold))
|
|
|
|
Spacer()
|
|
|
|
if pendingCount > 0 {
|
|
ShadcnBadge(title: "\(pendingCount) pending", tone: .ok)
|
|
if let oldestMinutes {
|
|
Text("oldest \(oldestMinutes) min ago")
|
|
.font(.caption)
|
|
.foregroundStyle(Color.idpMutedForeground)
|
|
}
|
|
} else {
|
|
ShadcnBadge(title: "all clear", tone: .ok)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct NotificationCenterView: View {
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
private var groupedNotifications: [(String, [AppNotification])] {
|
|
let calendar = Calendar.current
|
|
let groups = Dictionary(grouping: model.notifications) { calendar.startOfDay(for: $0.sentAt) }
|
|
|
|
return groups
|
|
.keys
|
|
.sorted(by: >)
|
|
.map { day in
|
|
(sectionTitle(for: day), groups[day]?.sorted(by: { $0.sentAt > $1.sentAt }) ?? [])
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if model.notifications.isEmpty {
|
|
EmptyPaneView(
|
|
title: "All clear",
|
|
message: "You'll see new sign-in requests here.",
|
|
systemImage: "shield"
|
|
)
|
|
} else {
|
|
List {
|
|
if model.notificationPermission == .unknown || model.notificationPermission == .denied {
|
|
NotificationPermissionCard(model: model)
|
|
.listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12))
|
|
.listRowSeparator(.hidden)
|
|
.listRowBackground(Color.clear)
|
|
}
|
|
|
|
ForEach(groupedNotifications, id: \.0) { section in
|
|
Section {
|
|
ForEach(section.1) { notification in
|
|
Button {
|
|
guard notification.isUnread else { return }
|
|
Task {
|
|
await model.markNotificationRead(notification)
|
|
}
|
|
} label: {
|
|
NotificationEventRow(notification: notification)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
} header: {
|
|
Text(section.0)
|
|
.textCase(nil)
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
}
|
|
}
|
|
.navigationTitle("Notifications")
|
|
}
|
|
|
|
private func sectionTitle(for date: Date) -> String {
|
|
if Calendar.current.isDateInToday(date) {
|
|
return "Today"
|
|
}
|
|
if Calendar.current.isDateInYesterday(date) {
|
|
return "Yesterday"
|
|
}
|
|
return date.formatted(.dateTime.month(.wide).day())
|
|
}
|
|
}
|
|
|
|
struct DevicesView: View {
|
|
@ObservedObject var model: AppViewModel
|
|
@State private var isPairingCodePresented = false
|
|
|
|
private var devices: [DevicePresentation] {
|
|
guard let session else { return [] }
|
|
|
|
let current = DevicePresentation(
|
|
name: session.deviceName,
|
|
systemImage: symbolName(for: session.deviceName),
|
|
lastSeen: .now,
|
|
isCurrent: true,
|
|
isTrusted: true
|
|
)
|
|
|
|
let others = [
|
|
DevicePresentation(name: "Phil's iPad Pro", systemImage: "ipad", lastSeen: .now.addingTimeInterval(-60 * 18), isCurrent: false, isTrusted: true),
|
|
DevicePresentation(name: "Berlin MacBook Pro", systemImage: "laptopcomputer", lastSeen: .now.addingTimeInterval(-60 * 74), isCurrent: false, isTrusted: true),
|
|
DevicePresentation(name: "Apple Watch", systemImage: "applewatch", lastSeen: .now.addingTimeInterval(-60 * 180), isCurrent: false, isTrusted: false)
|
|
]
|
|
|
|
let count = max((model.profile?.deviceCount ?? 1) - 1, 0)
|
|
return [current] + Array(others.prefix(count))
|
|
}
|
|
|
|
private var session: AuthSession? {
|
|
model.session
|
|
}
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section("This device") {
|
|
if let current = devices.first {
|
|
DeviceItemRow(device: current)
|
|
}
|
|
}
|
|
|
|
Section("Other devices · \(max(devices.count - 1, 0))") {
|
|
ForEach(Array(devices.dropFirst())) { device in
|
|
DeviceItemRow(device: device)
|
|
}
|
|
}
|
|
|
|
Section {
|
|
VStack(spacing: 12) {
|
|
Button("Pair another device") {
|
|
isPairingCodePresented = true
|
|
}
|
|
.buttonStyle(PrimaryActionStyle())
|
|
|
|
Button("Sign out everywhere") {
|
|
model.signOut()
|
|
}
|
|
.buttonStyle(DestructiveStyle())
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
}
|
|
.navigationTitle("Devices")
|
|
.sheet(isPresented: $isPairingCodePresented) {
|
|
if let session {
|
|
OneTimePasscodeSheet(session: session)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func symbolName(for deviceName: String) -> String {
|
|
let lowercased = deviceName.lowercased()
|
|
if lowercased.contains("ipad") {
|
|
return "ipad"
|
|
}
|
|
if lowercased.contains("watch") {
|
|
return "applewatch"
|
|
}
|
|
if lowercased.contains("mac") || lowercased.contains("safari") {
|
|
return "laptopcomputer"
|
|
}
|
|
return "iphone"
|
|
}
|
|
}
|
|
|
|
struct IdentityView: View {
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
var body: some View {
|
|
Form {
|
|
if let profile = model.profile {
|
|
Section("Identity") {
|
|
LabeledContent("Name", value: profile.name)
|
|
LabeledContent("Handle", value: profile.handle)
|
|
LabeledContent("Organization", value: profile.organization)
|
|
}
|
|
|
|
Section("Recovery") {
|
|
Text(profile.recoverySummary)
|
|
.font(.body)
|
|
}
|
|
}
|
|
|
|
if let session = model.session {
|
|
Section("Session") {
|
|
LabeledContent("Device", value: session.deviceName)
|
|
LabeledContent("Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
|
|
LabeledContent("Origin", value: session.originHost)
|
|
LabeledContent("Transport", value: session.pairingTransport.title)
|
|
}
|
|
|
|
Section("Pairing payload") {
|
|
Text(session.pairingCode)
|
|
.font(.footnote.monospaced())
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Identity")
|
|
}
|
|
}
|
|
|
|
struct SettingsView: View {
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section("Alerts") {
|
|
LabeledContent("Notifications", value: model.notificationPermission.title)
|
|
|
|
Button("Enable Notifications") {
|
|
Task {
|
|
await model.requestNotificationAccess()
|
|
}
|
|
}
|
|
.buttonStyle(SecondaryActionStyle())
|
|
|
|
Button("Send Test Notification") {
|
|
Task {
|
|
await model.sendTestNotification()
|
|
}
|
|
}
|
|
.buttonStyle(SecondaryActionStyle())
|
|
}
|
|
|
|
Section("Demo") {
|
|
Button("Simulate Incoming Request") {
|
|
Task {
|
|
await model.simulateIncomingRequest()
|
|
}
|
|
}
|
|
.buttonStyle(PrimaryActionStyle())
|
|
|
|
Button("Refresh") {
|
|
Task {
|
|
await model.refreshDashboard()
|
|
}
|
|
}
|
|
.buttonStyle(SecondaryActionStyle())
|
|
}
|
|
}
|
|
.navigationTitle("Settings")
|
|
}
|
|
}
|
|
|
|
enum PreviewFixtures {
|
|
static let profile = MemberProfile(
|
|
name: "Jurgen Meyer",
|
|
handle: "@jurgen",
|
|
organization: "idp.global",
|
|
deviceCount: 4,
|
|
recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified."
|
|
)
|
|
|
|
static let session = AuthSession(
|
|
deviceName: "iPhone 17 Pro",
|
|
originHost: "github.com",
|
|
pairedAt: .now.addingTimeInterval(-60 * 90),
|
|
tokenPreview: "berlin",
|
|
pairingCode: "idp.global://pair?token=swiftapp-demo-berlin&origin=github.com&device=iPhone%2017%20Pro",
|
|
pairingTransport: .preview
|
|
)
|
|
|
|
static let requests: [ApprovalRequest] = [
|
|
ApprovalRequest(
|
|
title: "Prove identity for GitHub",
|
|
subtitle: "GitHub is asking for a routine sign-in confirmation.",
|
|
source: "github.com",
|
|
createdAt: .now.addingTimeInterval(-60 * 4),
|
|
kind: .signIn,
|
|
risk: .routine,
|
|
scopes: ["email", "profile", "session:read"],
|
|
status: .pending
|
|
),
|
|
ApprovalRequest(
|
|
title: "Prove identity for workspace",
|
|
subtitle: "Your secure workspace needs a stronger proof before unlocking.",
|
|
source: "workspace.idp.global",
|
|
createdAt: .now.addingTimeInterval(-60 * 42),
|
|
kind: .elevatedAction,
|
|
risk: .elevated,
|
|
scopes: ["profile", "device", "location"],
|
|
status: .pending
|
|
),
|
|
ApprovalRequest(
|
|
title: "CLI session",
|
|
subtitle: "A CLI login was completed earlier today.",
|
|
source: "cli.idp.global",
|
|
createdAt: .now.addingTimeInterval(-60 * 120),
|
|
kind: .signIn,
|
|
risk: .routine,
|
|
scopes: ["profile"],
|
|
status: .approved
|
|
)
|
|
]
|
|
|
|
static let notifications: [AppNotification] = [
|
|
AppNotification(
|
|
title: "GitHub sign-in approved",
|
|
message: "Your latest sign-in request for github.com was approved.",
|
|
sentAt: .now.addingTimeInterval(-60 * 9),
|
|
kind: .approval,
|
|
isUnread: true
|
|
),
|
|
AppNotification(
|
|
title: "Recovery check passed",
|
|
message: "Backup recovery channels were verified in the last 24 hours.",
|
|
sentAt: .now.addingTimeInterval(-60 * 110),
|
|
kind: .system,
|
|
isUnread: false
|
|
),
|
|
AppNotification(
|
|
title: "Session expired",
|
|
message: "A pending workstation approval expired before it could be completed.",
|
|
sentAt: .now.addingTimeInterval(-60 * 1_500),
|
|
kind: .security,
|
|
isUnread: false
|
|
)
|
|
]
|
|
|
|
@MainActor
|
|
static func model() -> AppViewModel {
|
|
let model = AppViewModel(
|
|
service: MockIDPService.shared,
|
|
notificationCoordinator: PreviewNotificationCoordinator(),
|
|
appStateStore: PreviewStateStore(),
|
|
launchArguments: []
|
|
)
|
|
model.session = session
|
|
model.profile = profile
|
|
model.requests = requests
|
|
model.notifications = notifications
|
|
model.selectedSection = .inbox
|
|
model.manualPairingPayload = session.pairingCode
|
|
model.suggestedPairingPayload = session.pairingCode
|
|
model.notificationPermission = .allowed
|
|
return model
|
|
}
|
|
}
|
|
|
|
private struct PreviewNotificationCoordinator: NotificationCoordinating {
|
|
func authorizationStatus() async -> NotificationPermissionState { .allowed }
|
|
func requestAuthorization() async throws -> NotificationPermissionState { .allowed }
|
|
func scheduleTestNotification(title: String, body: String) async throws {}
|
|
}
|
|
|
|
private struct PreviewStateStore: AppStateStoring {
|
|
func load() -> PersistedAppState? { nil }
|
|
func save(_ state: PersistedAppState) {}
|
|
func clear() {}
|
|
}
|
|
|
|
#Preview("Inbox Light") {
|
|
NavigationStack {
|
|
InboxPreviewHost()
|
|
}
|
|
}
|
|
|
|
#Preview("Inbox Dark") {
|
|
NavigationStack {
|
|
InboxPreviewHost()
|
|
}
|
|
.preferredColorScheme(.dark)
|
|
}
|
|
|
|
@MainActor
|
|
private struct InboxPreviewHost: View {
|
|
@State private var selectedRequestID = PreviewFixtures.requests.first?.id
|
|
@State private var searchText = ""
|
|
@State private var isSearchPresented = false
|
|
@State private var model = PreviewFixtures.model()
|
|
|
|
var body: some View {
|
|
InboxListView(
|
|
model: model,
|
|
selectedRequestID: $selectedRequestID,
|
|
searchText: $searchText,
|
|
isSearchPresented: $isSearchPresented
|
|
)
|
|
}
|
|
}
|