Files
swiftapp/swift/Sources/Features/Home/HomePanels.swift
T

527 lines
18 KiB
Swift
Raw Normal View History

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
)
}
}