Some checks failed
CI / test (push) Has been cancelled
Tighten the inbox, detail, and watch layouts so approval actions feel denser and more direct across compact surfaces.
643 lines
21 KiB
Swift
643 lines
21 KiB
Swift
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)
|
|
}
|
|
}
|