2026-04-17 22:08:27 +02:00
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
struct LoginRootView: View {
|
|
|
|
|
@ObservedObject var model: AppViewModel
|
2026-04-19 16:29:13 +02:00
|
|
|
|
|
|
|
|
#if !os(macOS)
|
|
|
|
|
@State private var isNFCSheetPresented = false
|
|
|
|
|
#endif
|
2026-04-17 22:08:27 +02:00
|
|
|
|
|
|
|
|
var body: some View {
|
2026-04-19 16:29:13 +02:00
|
|
|
#if os(macOS)
|
|
|
|
|
MacPairingView(model: model)
|
|
|
|
|
#else
|
|
|
|
|
NavigationStack {
|
2026-04-20 18:40:32 +00:00
|
|
|
PairingWelcomeView(
|
|
|
|
|
model: model,
|
|
|
|
|
onScanRequested: {
|
|
|
|
|
model.isScannerPresented = true
|
|
|
|
|
},
|
|
|
|
|
onNFCRequested: {
|
|
|
|
|
isNFCSheetPresented = true
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
.fullScreenCover(isPresented: $model.isScannerPresented) {
|
|
|
|
|
QRScannerSheet(
|
|
|
|
|
title: "Scan pairing QR",
|
|
|
|
|
description: "Open pairing in your idp.global web session, then scan the QR shown there to link this iPhone.",
|
|
|
|
|
navigationTitle: "Link Passport"
|
|
|
|
|
) { payload in
|
2026-04-18 01:05:22 +02:00
|
|
|
model.manualPairingPayload = payload
|
2026-04-17 22:08:27 +02:00
|
|
|
Task {
|
2026-04-18 01:05:22 +02:00
|
|
|
await model.signIn(with: payload, transport: .qr)
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-20 18:40:32 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.sheet(isPresented: $isNFCSheetPresented) {
|
|
|
|
|
NFCSheet(
|
|
|
|
|
title: "Hold near pairing tag",
|
|
|
|
|
message: "Use a supported idp.global NFC tag to link this iPhone and attach a signed location proof.",
|
|
|
|
|
actionTitle: "Link device"
|
|
|
|
|
) { request in
|
|
|
|
|
await model.signIn(with: request)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-19 16:29:13 +02:00
|
|
|
|
2026-04-20 18:40:32 +00:00
|
|
|
#if !os(macOS)
|
|
|
|
|
private struct PairingWelcomeView: View {
|
|
|
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
|
let onScanRequested: () -> Void
|
|
|
|
|
let onNFCRequested: () -> Void
|
2026-04-19 16:29:13 +02:00
|
|
|
|
2026-04-20 18:40:32 +00:00
|
|
|
var body: some View {
|
2026-04-20 19:18:03 +00:00
|
|
|
AppScrollScreen(compactLayout: true, bottomPadding: 170) {
|
|
|
|
|
PairingHero()
|
2026-04-20 18:40:32 +00:00
|
|
|
|
2026-04-20 19:18:03 +00:00
|
|
|
PairingStepsCard()
|
2026-04-20 18:40:32 +00:00
|
|
|
|
2026-04-20 19:18:03 +00:00
|
|
|
PairingChecklistCard()
|
|
|
|
|
}
|
|
|
|
|
.navigationTitle("")
|
|
|
|
|
.toolbar(.hidden, for: .navigationBar)
|
|
|
|
|
.safeAreaInset(edge: .bottom) {
|
|
|
|
|
PairingActionDock(
|
|
|
|
|
isAuthenticating: model.isAuthenticating,
|
|
|
|
|
onScanRequested: onScanRequested,
|
|
|
|
|
onNFCRequested: onNFCRequested
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-20 18:40:32 +00:00
|
|
|
|
2026-04-20 19:18:03 +00:00
|
|
|
private struct PairingHero: View {
|
|
|
|
|
var body: some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
|
|
|
HeroGlyph()
|
2026-04-20 18:40:32 +00:00
|
|
|
|
2026-04-20 19:18:03 +00:00
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
Text("Your passport, in your pocket")
|
|
|
|
|
.font(.system(size: 30, weight: .bold, design: .default))
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
2026-04-20 18:40:32 +00:00
|
|
|
|
2026-04-20 19:18:03 +00:00
|
|
|
Text("Link this iPhone to your idp.global web session to approve sign-ins, receive security alerts, and prove your identity with NFC.")
|
|
|
|
|
.font(.subheadline)
|
2026-04-20 18:40:32 +00:00
|
|
|
.foregroundStyle(Color.idpMutedForeground)
|
2026-04-20 19:18:03 +00:00
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
2026-04-20 18:40:32 +00:00
|
|
|
}
|
2026-04-20 19:18:03 +00:00
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.padding(.top, 8)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-20 18:40:32 +00:00
|
|
|
|
2026-04-20 19:18:03 +00:00
|
|
|
private struct HeroGlyph: View {
|
|
|
|
|
var body: some View {
|
|
|
|
|
ZStack {
|
|
|
|
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
|
|
|
|
.fill(
|
|
|
|
|
LinearGradient(
|
|
|
|
|
colors: [
|
|
|
|
|
IdP.tint,
|
|
|
|
|
IdP.tint.opacity(0.78)
|
|
|
|
|
],
|
|
|
|
|
startPoint: .topLeading,
|
|
|
|
|
endPoint: .bottomTrailing
|
2026-04-20 18:40:32 +00:00
|
|
|
)
|
2026-04-20 19:18:03 +00:00
|
|
|
)
|
|
|
|
|
.frame(width: 76, height: 76)
|
|
|
|
|
.shadow(color: IdP.tint.opacity(0.35), radius: 18, x: 0, y: 10)
|
2026-04-20 18:40:32 +00:00
|
|
|
|
2026-04-20 19:18:03 +00:00
|
|
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
|
|
|
|
.stroke(Color.white.opacity(0.22), lineWidth: 1)
|
|
|
|
|
.frame(width: 76, height: 76)
|
2026-04-20 18:40:32 +00:00
|
|
|
|
2026-04-20 19:18:03 +00:00
|
|
|
Image(systemName: "shield.lefthalf.filled")
|
|
|
|
|
.font(.system(size: 34, weight: .semibold))
|
|
|
|
|
.foregroundStyle(.white)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct PairingStepsCard: View {
|
|
|
|
|
var body: some View {
|
|
|
|
|
AppPanel(compactLayout: true, radius: 22) {
|
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
|
|
|
Text("How pairing works")
|
|
|
|
|
.font(.headline)
|
|
|
|
|
Text("Three quick steps to finish setup.")
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
.foregroundStyle(Color.idpMutedForeground)
|
2026-04-18 01:05:22 +02:00
|
|
|
}
|
2026-04-20 18:40:32 +00:00
|
|
|
|
2026-04-20 19:18:03 +00:00
|
|
|
VStack(spacing: 0) {
|
|
|
|
|
PairingStepRow(
|
|
|
|
|
number: 1,
|
|
|
|
|
title: "Start from the web",
|
|
|
|
|
message: "Open idp.global in your browser and begin a new device pairing."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
StepDivider()
|
|
|
|
|
|
|
|
|
|
PairingStepRow(
|
|
|
|
|
number: 2,
|
|
|
|
|
title: "Scan the pairing QR",
|
|
|
|
|
message: "Point this iPhone at the QR shown by that browser session."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
StepDivider()
|
|
|
|
|
|
|
|
|
|
PairingStepRow(
|
|
|
|
|
number: 3,
|
|
|
|
|
title: "Approve future sign-ins",
|
|
|
|
|
message: "Once linked, this phone receives approval requests and alerts."
|
|
|
|
|
)
|
2026-04-20 18:40:32 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-20 19:18:03 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct PairingChecklistCard: View {
|
|
|
|
|
var body: some View {
|
|
|
|
|
AppPanel(compactLayout: true, radius: 22) {
|
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
|
|
|
Text("Before you scan")
|
|
|
|
|
.font(.headline)
|
|
|
|
|
Text("A few quick checks help the link complete cleanly.")
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
.foregroundStyle(Color.idpMutedForeground)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
PairingNoteRow(
|
|
|
|
|
icon: "safari",
|
|
|
|
|
text: "Keep the pairing page open in your browser until this phone confirms the link."
|
|
|
|
|
)
|
|
|
|
|
PairingNoteRow(
|
|
|
|
|
icon: "camera.fill",
|
|
|
|
|
text: "Grant camera access when prompted so the QR can be read directly."
|
|
|
|
|
)
|
|
|
|
|
PairingNoteRow(
|
|
|
|
|
icon: "doc.on.clipboard",
|
|
|
|
|
text: "No camera? The scanner also accepts a pasted pairing link."
|
|
|
|
|
)
|
|
|
|
|
PairingNoteRow(
|
|
|
|
|
icon: "wave.3.right",
|
|
|
|
|
text: "Organizations using NFC tags can link with NFC instead of QR."
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct PairingActionDock: View {
|
|
|
|
|
let isAuthenticating: Bool
|
|
|
|
|
let onScanRequested: () -> Void
|
|
|
|
|
let onNFCRequested: () -> Void
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
VStack(spacing: 10) {
|
|
|
|
|
Button(action: onScanRequested) {
|
|
|
|
|
HStack(spacing: 10) {
|
|
|
|
|
if isAuthenticating {
|
|
|
|
|
ProgressView()
|
|
|
|
|
.progressViewStyle(.circular)
|
|
|
|
|
.tint(Color.idpPrimaryForeground)
|
|
|
|
|
} else {
|
|
|
|
|
Image(systemName: "qrcode.viewfinder")
|
2026-04-19 16:29:13 +02:00
|
|
|
}
|
2026-04-20 18:40:32 +00:00
|
|
|
|
2026-04-20 19:18:03 +00:00
|
|
|
Text(isAuthenticating ? "Linking device..." : "Scan pairing QR")
|
2026-04-20 18:40:32 +00:00
|
|
|
}
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
2026-04-20 19:18:03 +00:00
|
|
|
.buttonStyle(PrimaryActionStyle())
|
|
|
|
|
.disabled(isAuthenticating)
|
|
|
|
|
|
|
|
|
|
Button(action: onNFCRequested) {
|
|
|
|
|
Label("Use NFC tag instead", systemImage: "wave.3.right")
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(SecondaryActionStyle())
|
|
|
|
|
.disabled(isAuthenticating)
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
2026-04-20 19:18:03 +00:00
|
|
|
.padding(.horizontal, 16)
|
|
|
|
|
.padding(.top, 12)
|
|
|
|
|
.padding(.bottom, 14)
|
|
|
|
|
.background(
|
|
|
|
|
Rectangle()
|
|
|
|
|
.fill(.regularMaterial)
|
|
|
|
|
.ignoresSafeArea()
|
|
|
|
|
.overlay(alignment: .top) {
|
|
|
|
|
Rectangle()
|
|
|
|
|
.fill(Color.idpSeparator.opacity(0.5))
|
|
|
|
|
.frame(height: 0.5)
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-04-20 18:40:32 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct PairingStepRow: View {
|
|
|
|
|
let number: Int
|
|
|
|
|
let title: String
|
|
|
|
|
let message: String
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
2026-04-20 19:18:03 +00:00
|
|
|
HStack(alignment: .top, spacing: 14) {
|
|
|
|
|
ZStack {
|
|
|
|
|
Circle()
|
|
|
|
|
.fill(IdP.tint.opacity(0.14))
|
|
|
|
|
Text("\(number)")
|
|
|
|
|
.font(.subheadline.weight(.bold))
|
|
|
|
|
.foregroundStyle(IdP.tint)
|
|
|
|
|
}
|
|
|
|
|
.frame(width: 30, height: 30)
|
2026-04-20 18:40:32 +00:00
|
|
|
|
2026-04-20 19:18:03 +00:00
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
2026-04-20 18:40:32 +00:00
|
|
|
Text(title)
|
|
|
|
|
.font(.subheadline.weight(.semibold))
|
|
|
|
|
Text(message)
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
.foregroundStyle(Color.idpMutedForeground)
|
2026-04-20 19:18:03 +00:00
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
2026-04-19 16:29:13 +02:00
|
|
|
}
|
2026-04-20 19:18:03 +00:00
|
|
|
|
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
|
}
|
|
|
|
|
.padding(.vertical, 10)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct StepDivider: View {
|
|
|
|
|
var body: some View {
|
|
|
|
|
HStack(spacing: 14) {
|
|
|
|
|
Rectangle()
|
|
|
|
|
.fill(Color.idpSeparator.opacity(0.6))
|
|
|
|
|
.frame(width: 1, height: 10)
|
|
|
|
|
.padding(.leading, 14)
|
|
|
|
|
Spacer()
|
2026-04-18 01:05:22 +02:00
|
|
|
}
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 18:40:32 +00:00
|
|
|
private struct PairingNoteRow: View {
|
|
|
|
|
let icon: String
|
|
|
|
|
let text: String
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
2026-04-20 19:18:03 +00:00
|
|
|
HStack(alignment: .top, spacing: 12) {
|
2026-04-20 18:40:32 +00:00
|
|
|
Image(systemName: icon)
|
2026-04-20 19:18:03 +00:00
|
|
|
.font(.system(size: 13, weight: .semibold))
|
2026-04-20 18:40:32 +00:00
|
|
|
.foregroundStyle(IdP.tint)
|
2026-04-20 19:18:03 +00:00
|
|
|
.frame(width: 26, height: 26)
|
|
|
|
|
.background(IdP.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
2026-04-20 18:40:32 +00:00
|
|
|
|
|
|
|
|
Text(text)
|
|
|
|
|
.font(.footnote)
|
2026-04-20 19:18:03 +00:00
|
|
|
.foregroundStyle(Color.idpForeground.opacity(0.82))
|
2026-04-20 18:40:32 +00:00
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
2026-04-20 19:18:03 +00:00
|
|
|
|
|
|
|
|
Spacer(minLength: 0)
|
2026-04-20 18:40:32 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
|
2026-04-19 16:29:13 +02:00
|
|
|
#if os(macOS)
|
|
|
|
|
private struct MacPairingView: View {
|
2026-04-17 22:08:27 +02:00
|
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
2026-04-19 16:29:13 +02:00
|
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
|
|
|
HStack(spacing: 12) {
|
|
|
|
|
Image(systemName: "shield.lefthalf.filled")
|
|
|
|
|
.font(.title2)
|
|
|
|
|
.foregroundStyle(IdP.tint)
|
|
|
|
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
|
|
|
Text("Set up idp.global")
|
|
|
|
|
.font(.headline)
|
|
|
|
|
Text("Use the demo payload or paste a pairing link.")
|
|
|
|
|
.font(.subheadline)
|
2026-04-17 22:08:27 +02:00
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
}
|
2026-04-18 01:05:22 +02:00
|
|
|
}
|
2026-04-17 22:08:27 +02:00
|
|
|
|
2026-04-19 16:29:13 +02:00
|
|
|
TextEditor(text: $model.manualPairingPayload)
|
|
|
|
|
.font(.footnote.monospaced())
|
|
|
|
|
.scrollContentBackground(.hidden)
|
|
|
|
|
.frame(minHeight: 140)
|
|
|
|
|
.padding(10)
|
|
|
|
|
.background(Color.idpSecondaryGroupedBackground, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous))
|
2026-04-17 22:08:27 +02:00
|
|
|
|
2026-04-19 16:29:13 +02:00
|
|
|
VStack(spacing: 10) {
|
|
|
|
|
Button("Use demo payload") {
|
|
|
|
|
Task {
|
|
|
|
|
await model.signInWithSuggestedPayload()
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-19 16:29:13 +02:00
|
|
|
.buttonStyle(PrimaryActionStyle())
|
2026-04-18 01:05:22 +02:00
|
|
|
|
2026-04-19 16:29:13 +02:00
|
|
|
Button("Link with payload") {
|
|
|
|
|
Task {
|
|
|
|
|
await model.signInWithManualPayload()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(SecondaryActionStyle())
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-19 16:29:13 +02:00
|
|
|
.padding(20)
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-19 16:29:13 +02:00
|
|
|
#endif
|