import SwiftUI struct ApprovalDetailView: View { @ObservedObject var model: AppViewModel let requestID: ApprovalRequest.ID? var dismissOnResolve = false @Environment(\.dismiss) private var dismiss private var request: ApprovalRequest? { guard let requestID else { return nil } return model.requests.first(where: { $0.id == requestID }) } var body: some View { Group { if let request { ScrollView { VStack(alignment: .leading, spacing: 14) { RequestHeroCard( request: request, handle: model.profile?.handle ?? "@you" ) ShadcnMetaCard(rows: [ .init(label: "From device", value: request.deviceSummary), .init(label: "Location", value: request.locationSummary), .init(label: "Network", value: request.networkSummary), .init(label: "IP", value: request.ipSummary, monospaced: true) ]) SectionHeader(title: "Will share") ShadcnScopesCard(scopes: request.scopes, profile: model.profile) TrustSignalBanner(request: request) } .padding(.horizontal, 16) .padding(.top, 14) .padding(.bottom, 110) } .scrollIndicators(.hidden) .background(Color.idpBackground) .navigationTitle(request.appDisplayName) .idpInlineNavigationTitle() .toolbar { ToolbarItem(placement: .idpTrailingToolbar) { ShadcnBadge( title: "expires \(formattedExpires(request.expiresAt))", tone: .outline, leading: Image(systemName: "clock") ) } } .safeAreaInset(edge: .bottom) { if request.status == .pending { HStack(spacing: 8) { Button("Deny") { Task { await performReject(request) } } .buttonStyle(SecondaryActionStyle()) HoldToApproveButton(isBusy: model.activeRequestID == request.id) { await performApprove(request) } } .padding(.horizontal, 16) .padding(.vertical, 12) .background(Color.idpBackground) .overlay(alignment: .top) { Rectangle() .fill(Color.idpBorder) .frame(height: 1) } } } .background { keyboardShortcuts(for: request) } } else { EmptyPaneView( title: "Nothing selected", message: "Choose a sign-in request from the inbox to review the full context.", systemImage: "checkmark.circle" ) } } } private func formattedExpires(_ date: Date) -> String { let seconds = Int(max(0, date.timeIntervalSince(.now))) let m = seconds / 60 let s = seconds % 60 return String(format: "%d:%02d", m, s) } @ViewBuilder private func keyboardShortcuts(for request: ApprovalRequest) -> some View { Group { Button("Approve") { Task { await performApprove(request) } } .keyboardShortcut(.return, modifiers: .command) .hidden() .accessibilityHidden(true) Button("Deny") { Task { await performReject(request) } } .keyboardShortcut(.delete, modifiers: .command) .hidden() .accessibilityHidden(true) } } private func performApprove(_ request: ApprovalRequest) async { guard model.activeRequestID != request.id else { return } await model.approve(request) if dismissOnResolve { dismiss() } } private func performReject(_ request: ApprovalRequest) async { guard model.activeRequestID != request.id else { return } Haptics.warning() await model.reject(request) if dismissOnResolve { dismiss() } } } struct RequestDetailSheet: View { let request: ApprovalRequest @ObservedObject var model: AppViewModel var body: some View { NavigationStack { ApprovalDetailView(model: model, requestID: request.id, dismissOnResolve: true) } } } struct HoldToApproveButton: View { var title = "Hold to approve" var isBusy = false let action: () async -> Void @State private var progress: CGFloat = 0 var body: some View { ZStack { RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) .fill(isBusy ? Color.idpMuted : Color.idpPrimary) label .padding(.horizontal, 20) GeometryReader { _ in RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) .trim(from: 0, to: progress) .stroke(IdP.tint, style: StrokeStyle(lineWidth: 2, lineCap: .round)) .rotationEffect(.degrees(-90)) .padding(1.5) } } .frame(height: 44) .contentShape(RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)) .onLongPressGesture(minimumDuration: 0.6, maximumDistance: 20, pressing: updateProgress) { guard !isBusy else { return } Task { Haptics.success() await action() progress = 0 } } .accessibilityAddTraits(.isButton) .accessibilityLabel(title) .accessibilityHint("Press and hold to approve this request.") } @ViewBuilder private var label: some View { if isBusy { ProgressView() .tint(Color.idpPrimaryForeground) } else { HStack(spacing: 6) { Image(systemName: "checkmark") Text(title) } .font(.subheadline.weight(.semibold)) .foregroundStyle(Color.idpPrimaryForeground) } } private func updateProgress(_ isPressing: Bool) { guard !isBusy else { return } withAnimation(.linear(duration: isPressing ? 0.6 : 0.15)) { progress = isPressing ? 1 : 0 } } } struct ShadcnMetaCard: View { struct Row: Identifiable { let id = UUID() let label: String let value: String var monospaced: Bool = false } let rows: [Row] var body: some View { VStack(spacing: 0) { ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in HStack { Text(row.label) .foregroundStyle(Color.idpMutedForeground) Spacer() Text(row.value) .font(row.monospaced ? .footnote.monospaced() : .footnote.weight(.medium)) .foregroundStyle(.primary) } .font(.footnote) .padding(.horizontal, 14) .padding(.vertical, 10) if index < rows.count - 1 { Rectangle() .fill(Color.idpBorder) .frame(height: 1) } } } .background(Color.idpCard, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) .stroke(Color.idpBorder, lineWidth: 1) ) } } struct ShadcnScopesCard: View { let scopes: [String] let profile: MemberProfile? var body: some View { VStack(spacing: 0) { ForEach(Array(scopes.enumerated()), id: \.offset) { index, scope in HStack(spacing: 10) { Image(systemName: icon(for: scope)) .font(.caption) .foregroundStyle(Color.idpMutedForeground) .frame(width: 16) Text(label(for: scope)) .font(.footnote.weight(.medium)) Spacer() Text(value(for: scope)) .font(.footnote) .foregroundStyle(Color.idpMutedForeground) Image(systemName: "checkmark") .font(.caption.weight(.semibold)) .foregroundStyle(Color.idpOK) } .padding(.horizontal, 14) .padding(.vertical, 10) if index < scopes.count - 1 { Rectangle() .fill(Color.idpBorder) .frame(height: 1) } } } .background(Color.idpCard, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) .stroke(Color.idpBorder, lineWidth: 1) ) } private func icon(for scope: String) -> String { let key = scope.lowercased() if key.contains("email") { return "envelope" } if key.contains("location") { return "location" } if key.contains("profile") { return "person" } if key.contains("device") { return "iphone" } if key.contains("session") { return "key" } return "checkmark.seal" } private func label(for scope: String) -> String { let key = scope.lowercased() if key.contains("email") { return "Email address" } if key.contains("location") { return "Coarse location" } if key.contains("profile") { return "Profile name" } if key.contains("device") { return "Device posture" } if key.contains("session") { return "Session read" } return scope.capitalized } private func value(for scope: String) -> String { let key = scope.lowercased() if key.contains("email") { return "\(profile?.handle ?? "@you")" } if key.contains("location") { return "Berlin region" } if key.contains("profile") { return profile?.name ?? "You" } return "Yes" } } struct SectionHeader: View { let title: String var body: some View { Text(title) .font(.caption2.weight(.semibold)) .tracking(0.5) .textCase(.uppercase) .foregroundStyle(Color.idpMutedForeground) .padding(.leading, 4) .padding(.top, 4) } } struct NFCSheet: View { var title = "Hold near reader" var message = "Tap to confirm sign-in. Your location will be signed and sent." var actionTitle = "Approve" let onSubmit: (PairingAuthenticationRequest) async -> Void @Environment(\.dismiss) private var dismiss @StateObject private var reader = NFCIdentifyReader() @State private var pendingRequest: PairingAuthenticationRequest? @State private var isSubmitting = false @State private var pulse = false private var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } var body: some View { VStack(spacing: 20) { ZStack { ForEach(0..<3, id: \.self) { index in Circle() .stroke(IdP.tint.opacity(0.55 - Double(index) * 0.18), lineWidth: 1) .frame(width: 96 + CGFloat(index * 24), height: 96 + CGFloat(index * 24)) .scaleEffect(pulse ? 1.10 : 0.92) .opacity(pulse ? 0.0 : 0.9) .animation(.easeOut(duration: 2.0).repeatForever(autoreverses: false).delay(Double(index) * 0.5), value: pulse) } Circle() .fill(IdP.tint) .frame(width: 56, height: 56) .overlay( Image(systemName: "wave.3.right") .font(.system(size: 22, weight: .semibold)) .foregroundStyle(.white) ) } .frame(height: 130) VStack(spacing: 6) { Text(title) .font(.title3.weight(.bold)) Text(message) .font(.footnote) .foregroundStyle(Color.idpMutedForeground) .multilineTextAlignment(.center) } HStack(spacing: 8) { Button("Cancel") { dismiss() } .buttonStyle(SecondaryActionStyle()) Button(primaryTitle) { guard let pendingRequest else { return } Task { isSubmitting = true await onSubmit(pendingRequest) isSubmitting = false dismiss() } } .buttonStyle(PrimaryActionStyle()) .disabled(pendingRequest == nil || isSubmitting) } } .padding(24) .background(Color.idpBackground) .presentationDetents([.medium]) .presentationDragIndicator(.visible) .task { pulse = true reader.onAuthenticationRequestDetected = { request in pendingRequest = request Haptics.selection() } reader.onError = { _ in } guard !isPreview else { return } reader.beginScanning() } } private var primaryTitle: String { if isSubmitting { return "Approving…" } return pendingRequest == nil ? "Waiting…" : actionTitle } } struct OneTimePasscodeSheet: View { let session: AuthSession @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { TimelineView(.periodic(from: .now, by: 1)) { context in let code = OneTimePasscodeGenerator.code(for: session.pairingCode, at: context.date) let secondsRemaining = OneTimePasscodeGenerator.renewalCountdown(at: context.date) VStack(alignment: .leading, spacing: 18) { Text("One-time pairing code") .font(.title3.weight(.semibold)) Text("Use this code on the next device you want to pair with your idp.global passport.") .font(.subheadline) .foregroundStyle(.secondary) Text(code) .font(.system(size: 42, weight: .bold, design: .rounded).monospacedDigit()) .tracking(5) .frame(maxWidth: .infinity) .padding(.vertical, 18) .background(Color.idpSecondaryGroupedBackground, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) HStack { StatusPill(title: "Renews in \(secondsRemaining)s", color: IdP.tint) StatusPill(title: session.originHost, color: .secondary) } Spacer() } .padding(24) } .navigationTitle("Pair Device") .idpInlineNavigationTitle() .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } } } } } } struct MenuBarPopover: View { @ObservedObject var model: AppViewModel @State private var notificationsPaused = false @State private var isPairingCodePresented = false var body: some View { VStack(alignment: .leading, spacing: 18) { header if let request = model.pendingRequests.first { RequestHeroCard(request: request, handle: model.profile?.handle ?? "@you") } else { EmptyPaneView( title: "Inbox clear", message: "New sign-in requests will appear here.", systemImage: "shield" ) .approvalCard() } if model.pendingRequests.count > 1 { VStack(alignment: .leading, spacing: 6) { Text("Queued") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) ForEach(model.pendingRequests.dropFirst().prefix(3)) { request in ApprovalRow(request: request, handle: model.profile?.handle ?? "@you", compact: true) } } } Divider() VStack(spacing: 8) { Button { model.selectedSection = .inbox } label: { MenuRowLabel(title: "Open inbox", systemImage: "tray.full") } .buttonStyle(.plain) .keyboardShortcut("o", modifiers: .command) Button { isPairingCodePresented = true } label: { MenuRowLabel(title: "Pair new device", systemImage: "plus.viewfinder") } .buttonStyle(.plain) .keyboardShortcut("n", modifiers: .command) Button { notificationsPaused.toggle() Haptics.selection() } label: { MenuRowLabel(title: notificationsPaused ? "Resume notifications" : "Pause notifications", systemImage: notificationsPaused ? "bell.badge" : "bell.slash") } .buttonStyle(.plain) Button { model.selectedSection = .settings } label: { MenuRowLabel(title: "Preferences", systemImage: "gearshape") } .buttonStyle(.plain) .keyboardShortcut(",", modifiers: .command) } } .padding(20) .sheet(isPresented: $isPairingCodePresented) { if let session = model.session { OneTimePasscodeSheet(session: session) } } } private var header: some View { HStack(alignment: .center, spacing: 12) { Image(systemName: "shield.lefthalf.filled") .font(.title2) .foregroundStyle(IdP.tint) VStack(alignment: .leading, spacing: 2) { Text("idp.global") .font(.headline) StatusPill(title: "Connected", color: .green) } Spacer() } } } private struct MenuRowLabel: View { let title: String let systemImage: String var body: some View { HStack(spacing: 12) { Image(systemName: systemImage) .frame(width: 18) .foregroundStyle(IdP.tint) Text(title) Spacer() } .padding(.vertical, 6) .contentShape(Rectangle()) } } #Preview("Approval Detail Light") { NavigationStack { ApprovalDetailPreviewHost() } } #Preview("Approval Detail Dark") { NavigationStack { ApprovalDetailPreviewHost() } .preferredColorScheme(.dark) } #Preview("NFC Sheet Light") { NFCSheet { _ in } } #Preview("NFC Sheet Dark") { NFCSheet { _ in } .preferredColorScheme(.dark) } #Preview("Request Hero Card Light") { RequestHeroCard(request: PreviewFixtures.requests[0], handle: PreviewFixtures.profile.handle) .padding() } #Preview("Request Hero Card Dark") { RequestHeroCard(request: PreviewFixtures.requests[0], handle: PreviewFixtures.profile.handle) .padding() .preferredColorScheme(.dark) } #if os(macOS) #Preview("Menu Bar Popover Light") { MenuBarPopover(model: PreviewFixtures.model()) .frame(width: 420) } #Preview("Menu Bar Popover Dark") { MenuBarPopover(model: PreviewFixtures.model()) .frame(width: 420) .preferredColorScheme(.dark) } #endif @MainActor private struct ApprovalDetailPreviewHost: View { @State private var model = PreviewFixtures.model() var body: some View { ApprovalDetailView(model: model, requestID: PreviewFixtures.requests.first?.id) } }