Some checks failed
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.
468 lines
16 KiB
Swift
468 lines
16 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
|
|
}
|
|
|
|
var body: some View {
|
|
List {
|
|
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"
|
|
)
|
|
.listRowBackground(Color.clear)
|
|
} else {
|
|
ForEach(recentRequests) { request in
|
|
row(for: request, compact: false)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
}
|
|
|
|
if !earlierRequests.isEmpty {
|
|
Section {
|
|
ForEach(earlierRequests) { request in
|
|
row(for: request, compact: true)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
}
|
|
} header: {
|
|
Text("Earlier today")
|
|
.textCase(nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.navigationTitle("Inbox")
|
|
.animation(.spring(response: 0.35, dampingFraction: 0.88), value: filteredRequests.map(\.id))
|
|
.idpSearchable(text: $searchText, isPresented: $isSearchPresented)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func row(for request: ApprovalRequest, compact: Bool) -> some View {
|
|
if usesSelection {
|
|
Button {
|
|
selectedRequestID = request.id
|
|
Haptics.selection()
|
|
} label: {
|
|
ApprovalRow(
|
|
request: request,
|
|
handle: model.profile?.handle ?? "@you",
|
|
compact: compact,
|
|
highlighted: highlightedRequestID == request.id
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
|
.listRowSeparator(.hidden)
|
|
.listRowBackground(Color.clear)
|
|
} else {
|
|
NavigationLink(value: request.id) {
|
|
ApprovalRow(
|
|
request: request,
|
|
handle: model.profile?.handle ?? "@you",
|
|
compact: compact,
|
|
highlighted: highlightedRequestID == request.id
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
|
.listRowSeparator(.hidden)
|
|
.listRowBackground(Color.clear)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|