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.
527 lines
18 KiB
Swift
527 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
|
|
|
|
private var devices: [DevicePresentation] {
|
|
guard let session else { return [] }
|
|
|
|
if !model.devices.isEmpty {
|
|
return model.devices.map { device in
|
|
DevicePresentation(
|
|
name: device.label,
|
|
systemImage: symbolName(for: device.label),
|
|
lastSeen: device.lastSeenAt ?? session.pairedAt,
|
|
isCurrent: device.isCurrent,
|
|
isTrusted: true
|
|
)
|
|
}
|
|
}
|
|
|
|
return [
|
|
DevicePresentation(
|
|
name: session.deviceName,
|
|
systemImage: symbolName(for: session.deviceName),
|
|
lastSeen: .now,
|
|
isCurrent: true,
|
|
isTrusted: true
|
|
)
|
|
]
|
|
}
|
|
|
|
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) {
|
|
Text("Start new device pairing from your idp.global web session, then scan the fresh pairing QR in this app.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Button("Sign out everywhere") {
|
|
model.signOut()
|
|
}
|
|
.buttonStyle(DestructiveStyle())
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
}
|
|
.navigationTitle("Devices")
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|