Repository navigation
Expand file tree
/
Copy pathAuthFlowComponents.swift
More file actions
322 lines (276 loc) · 10.3 KB
/
Copy pathAuthFlowComponents.swift
File metadata and controls
322 lines (276 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
import SwiftUI
import UIKit
/// Shared UI building blocks for the cloud-backup auth flows (Google +
/// Apple). Lifted from `GoogleAuthView.swift` so both flows render the same
/// PIN entry, account chooser, error, and loading screens. Provider-specific
/// copy (e.g. "Google" vs "Apple", "Google Drive" vs "iCloud") is passed in
/// as a `providerName` parameter where it appears in user-facing text.
/// One backup entry rendered in the account chooser. Provider-agnostic: the
/// `backupID` is opaque (Drive file ID or iCloud Keychain account) and round
/// trips back to the originating view-model when the user taps a row.
struct AuthBackupSummary: Identifiable, Equatable {
let backupID: String
let npub: String
let pubkeyHex: String
var displayName: String?
var picture: String?
var id: String { npub }
}
// MARK: - Header
struct AuthFlowHeader: View {
var body: some View {
VStack(spacing: 0) {
Image("WispLogo")
.resizable()
.scaledToFit()
.frame(width: 96, height: 96)
.shadow(color: .black.opacity(0.6), radius: 30, y: 10)
Text("wisp")
.font(.system(size: 36, weight: .medium))
.foregroundStyle(.white)
}
}
}
// MARK: - Loading
struct AuthFlowLoadingBlock: View {
let label: String
var body: some View {
VStack(spacing: 16) {
ProgressView()
.tint(.wispPrimary)
Text(label)
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - PIN setup (enter + confirm)
struct AuthFlowSetupPinBlock: View {
let isEntry: Bool
let mismatch: Bool
let onSubmitEntry: (String) -> Void
let onSubmitConfirm: (String) -> Void
let onBackToEntry: () -> Void
@State private var pin: String = ""
var body: some View {
VStack(spacing: 12) {
Text(isEntry ? "Set a recovery PIN" : "Confirm your PIN")
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
Text(isEntry
? "Pick a 4\u{2013}8 digit PIN. You\u{2019}ll need it to restore your account on a new device."
: "Enter your PIN again to make sure you remember it.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if isEntry {
Text("If you forget this PIN, your Nostr key is lost forever \u{2014} there is no reset.")
.font(.footnote.weight(.semibold))
.foregroundStyle(.red)
.multilineTextAlignment(.center)
.padding(.top, 4)
}
AuthFlowPinField(
pin: $pin,
errorText: isEntry && mismatch ? "PINs didn\u{2019}t match. Try again." : nil,
onSubmit: { submit() }
)
.padding(.top, 8)
Button(action: submit) {
Text("Continue").frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.wispPrimary)
.controlSize(.large)
.disabled(pin.count < 4 || pin.count > 8)
.padding(.top, 8)
if !isEntry {
Button("Back", action: onBackToEntry)
.foregroundStyle(.secondary)
.padding(.top, 4)
}
}
.onChange(of: isEntry) { _, _ in pin = "" }
}
private func submit() {
if isEntry { onSubmitEntry(pin) } else { onSubmitConfirm(pin) }
}
}
// MARK: - PIN restore
struct AuthFlowRestorePinBlock: View {
let providerName: String
let attemptFailed: Bool
let onSubmit: (String) -> Void
@State private var pin: String = ""
var body: some View {
VStack(spacing: 12) {
Text("Enter your recovery PIN")
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("This is the PIN you set when you first signed in to Wisp with this \(providerName) account.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
AuthFlowPinField(
pin: $pin,
errorText: attemptFailed ? "Incorrect PIN. Try again." : nil,
onSubmit: { onSubmit(pin) }
)
.padding(.top, 8)
Button {
onSubmit(pin)
} label: {
Text("Unlock").frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.wispPrimary)
.controlSize(.large)
.disabled(pin.count < 4 || pin.count > 8)
.padding(.top, 8)
}
}
}
// MARK: - PIN field
struct AuthFlowPinField: View {
@Binding var pin: String
let errorText: String?
let onSubmit: () -> Void
@FocusState private var focused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 4) {
SecureField("PIN", text: $pin)
.focused($focused)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.submitLabel(.done)
.onSubmit { onSubmit() }
.onChange(of: pin) { _, newValue in
let filtered = String(newValue.filter { $0.isASCII && $0.isNumber }.prefix(8))
if filtered != newValue { pin = filtered }
}
.padding(12)
.background(Color.wispSurfaceVariant.opacity(0.4), in: RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(errorText != nil ? Color.red : Color.wispOutline.opacity(0.4), lineWidth: 1)
)
Text(errorText ?? "Use 4\u{2013}8 digits")
.font(.caption)
.foregroundStyle(errorText != nil ? .red : .secondary)
.padding(.leading, 4)
}
.onAppear { focused = true }
}
}
// MARK: - Account chooser
struct AuthFlowChooseBlock: View {
let providerStorageName: String
let backups: [AuthBackupSummary]
let onRestore: (AuthBackupSummary) -> Void
let onCreate: () -> Void
var body: some View {
VStack(spacing: 12) {
Text("Your backed-up accounts")
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("Tap an account to restore it, or create a new one. New accounts are encrypted and added to your \(providerStorageName) backup.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
ScrollView {
VStack(spacing: 8) {
ForEach(backups) { backup in
AuthFlowBackupRow(backup: backup, onTap: { onRestore(backup) })
}
}
.padding(.top, 8)
}
.frame(maxHeight: 280)
Divider().background(Color.wispOutline)
Button(action: onCreate) {
Text("Create another account").frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.wispPrimary)
.controlSize(.large)
}
}
}
struct AuthFlowBackupRow: View {
let backup: AuthBackupSummary
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
CachedAvatarView(url: backup.picture, size: 40)
VStack(alignment: .leading, spacing: 2) {
Text(backup.displayName?.authFlowNonEmpty ?? Self.formatShortNpub(backup.npub))
.font(.body)
.foregroundStyle(.primary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.wispSurfaceVariant, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
private static func formatShortNpub(_ npub: String) -> String {
guard npub.count > 18 else { return npub }
return "\(npub.prefix(12))\u{2026}\(npub.suffix(6))"
}
}
// MARK: - Error block
struct AuthFlowErrorBlock: View {
let message: String
let onRetry: () -> Void
let onCancel: () -> Void
var body: some View {
VStack(spacing: 16) {
Text(message)
.font(.callout)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
Button(action: onRetry) {
Text("Retry").frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.wispPrimary)
.controlSize(.large)
Button(action: onCancel) {
Text("Cancel").frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.wispPrimary)
.controlSize(.large)
}
}
}
extension String {
var authFlowNonEmpty: String? { isEmpty ? nil : self }
}
/// Walks the active foreground scene to find the top-most presented view
/// controller. Google's SDK and Apple's `ASAuthorizationController` both
/// need a real `UIViewController` to anchor their consent UI; SwiftUI
/// doesn't hand us one directly.
@MainActor
func authFlowTopMostViewController() -> UIViewController? {
let scene = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first(where: { $0.activationState == .foregroundActive })
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
guard let root = scene?.windows.first(where: { $0.isKeyWindow })?.rootViewController
?? scene?.windows.first?.rootViewController else { return nil }
var top = root
while let presented = top.presentedViewController {
top = presented
}
return top
}