gitbay/Views/Account/AccountView.swift
516 lines · 21222 bytes
1import SwiftUI
2
3/// SSH keys, PGP keys, email verification and notification settings.
4/// API tokens have their own screen.
5struct AccountView: View {
6
7 @Environment(SessionStore.self) private var session
8 @State private var model: AccountViewModel
9 @State private var deletingAccount = false
10 @State private var deleteConfirm = ""
11 @State private var deletionRefusal: String?
12 @State private var addingSSH = false
13 @State private var addingPGP = false
14 @State private var emailAddress = ""
15 @State private var verifyCode = ""
16 @State private var removingEmail: AccountViewModel.EmailAddress?
17 @State private var removingSSH: AccountViewModel.SSHKey?
18 @State private var sshConfirmText = ""
19 @State private var labellingSSH: AccountViewModel.SSHKey?
20 @State private var sshLabelText = ""
21 @State private var removingPGP: AccountViewModel.PGPKey?
22
23 init(client: GitbayClient) {
24 _model = State(initialValue: AccountViewModel(client: client))
25 }
26
27 var body: some View {
28 List {
29 if let loaded = model.state.value {
30 if !loaded.orgs.isEmpty {
31 Section("Organizations") {
32 ForEach(loaded.orgs) { membership in
33 NavigationLink(value: OrgRoute.org(membership.org)) {
34 HStack {
35 Label(membership.org, systemImage: "building.2")
36 .font(.gbSans(.subheadline))
37 Spacer()
38 GBChip(membership.role, .secondary)
39 }
40 }
41 }
42 }
43 }
44 sshSection(loaded.sshKeys)
45 pgpSection(loaded.pgpKeys)
46 notificationsSection
47 // Feedback sits beside the email actions: with the
48 // keyboard up, a banner at the top of the list is
49 // scrolled out of existence.
50 if let error = model.actionError {
51 Section {
52 GBNotice(error, .gbWarn)
53 }
54 }
55 if let notice = model.notice {
56 Section {
57 GBNotice(notice, .gbOK)
58 }
59 }
60 emailSection
61 deleteAccountSection
62 }
63 }
64 .overlay { LoadStateOverlay(state: model.state) }
65 .navigationTitle("Account")
66 .navigationBarTitleDisplayMode(.inline)
67 .task { await model.load() }
68 .task { await model.loadNotificationPreferences() }
69 .task { await model.loadEmails() }
70 .task { model.loadPendingDeletion(username: username) }
71 .alert("Not deleted", isPresented: Binding(get: { deletionRefusal != nil }, set: { if !$0 { deletionRefusal = nil } })) {
72 Button("OK", role: .cancel) {}
73 } message: {
74 Text(deletionRefusal ?? "")
75 }
76 .alert("Delete your account", isPresented: $deletingAccount) {
77 TextField("type \(username) to confirm", text: $deleteConfirm)
78 .autocorrectionDisabled()
79 .textInputAutocapitalization(.never)
80 Button("Mail the link", role: .destructive) {
81 Task {
82 await model.requestDeletion(username: username)
83 // The list's banner is far above this last row.
84 if model.deletionMailedTo == nil { deletionRefusal = model.actionError }
85 }
86 }
87 .disabled(deleteConfirm != username)
88 Button("Cancel", role: .cancel) {}
89 } message: {
90 Text("We mail a link to your address. Opening it disables the account at once, and seven days later deletes it: repositories, snippets, keys and addresses. What you wrote on other people's repositories stays, under the name ghost. Type \(username) to confirm.")
91 }
92 .confirmationDialog(
93 "Remove \(removingEmail?.address ?? "")?",
94 isPresented: Binding(
95 get: { removingEmail != nil },
96 set: { if !$0 { removingEmail = nil } }
97 ),
98 titleVisibility: .visible
99 ) {
100 Button("Remove", role: .destructive) {
101 if let email = removingEmail {
102 Task { await model.removeEmail(email.address) }
103 }
104 removingEmail = nil
105 }
106 Button("Cancel", role: .cancel) {}
107 }
108 .refreshable {
109 await model.load()
110 await model.loadNotificationPreferences()
111 await model.loadEmails()
112 }
113 .sheet(isPresented: $addingSSH) {
114 KeyPasteSheet(
115 heading: "Add SSH Key",
116 prompt: "Paste an authorized_keys line (ssh-ed25519 AAAA…)",
117 scopes: ["full", "git"],
118 working: model.working,
119 errorMessage: model.actionError
120 ) { text, scope, label, ttl in
121 Task {
122 await model.addSSHKey(text, scope: scope ?? "full", label: label, ttl: ttl)
123 if model.actionError == nil { addingSSH = false }
124 }
125 }
126 }
127 .sheet(isPresented: $addingPGP) {
128 KeyPasteSheet(
129 heading: "Add PGP Key",
130 prompt: "Paste an armored public key (-----BEGIN PGP PUBLIC KEY BLOCK-----)",
131 scopes: nil,
132 working: model.working,
133 errorMessage: model.actionError
134 ) { text, _, _, _ in
135 Task {
136 await model.addPGPKey(text)
137 if model.actionError == nil { addingPGP = false }
138 }
139 }
140 }
141 .alert(
142 "Label this key",
143 isPresented: Binding(
144 get: { labellingSSH != nil },
145 set: { if !$0 { labellingSSH = nil } }
146 ),
147 presenting: labellingSSH
148 ) { key in
149 TextField("Label", text: $sshLabelText)
150 .autocorrectionDisabled()
151 .textInputAutocapitalization(.never)
152 Button("Save") {
153 Task { await model.labelSSHKey(key, sshLabelText) }
154 labellingSSH = nil
155 }
156 Button("Cancel", role: .cancel) { labellingSSH = nil }
157 } message: { _ in
158 Text("An empty label clears it.")
159 }
160 .alert(
161 "Remove this SSH key?",
162 isPresented: Binding(
163 get: { removingSSH != nil },
164 set: { if !$0 { removingSSH = nil } }
165 ),
166 presenting: removingSSH
167 ) { key in
168 TextField("type \(key.confirmation) to confirm", text: $sshConfirmText)
169 .autocorrectionDisabled()
170 .textInputAutocapitalization(.never)
171 Button("Remove", role: .destructive) {
172 Task { await model.removeSSHKey(key) }
173 removingSSH = nil
174 }
175 .disabled(sshConfirmText != key.confirmation)
176 Button("Cancel", role: .cancel) { removingSSH = nil }
177 } message: { key in
178 Text("Anything authenticating with it loses access. Type \(key.confirmation) to confirm.")
179 }
180 .confirmationDialog(
181 "Remove this PGP key? Commits it signed become unverifiable.",
182 isPresented: Binding(
183 get: { removingPGP != nil },
184 set: { if !$0 { removingPGP = nil } }
185 )
186 ) {
187 Button("Remove", role: .destructive) {
188 if let key = removingPGP {
189 Task { await model.removePGPKey(key) }
190 }
191 removingPGP = nil
192 }
193 Button("Cancel", role: .cancel) {}
194 }
195 }
196
197 // MARK: - Sections
198
199 private var username: String { session.current?.username ?? "" }
200
201 /// Deleting the account is confirmed by mail, as on the web: nothing
202 /// changes until the link is opened, and signing in during the seven
203 /// days after cancels it.
204 private var deleteAccountSection: some View {
205 Section {
206 if let address = model.deletionMailedTo {
207 Label("A link went to \(address). It works for 24 hours; nothing changes until it is opened.",
208 systemImage: "envelope")
209 .font(.gbSans(.subheadline))
210 .accessibilityIdentifier("account-delete-mailed")
211 Button("Withdraw the request") {
212 Task { await model.cancelDeletion(username: username) }
213 }
214 .disabled(model.working)
215 .accessibilityIdentifier("account-delete-cancel")
216 } else {
217 Button(role: .destructive) {
218 deleteConfirm = ""
219 deletingAccount = true
220 } label: {
221 Label("Delete account", systemImage: "person.crop.circle.badge.xmark")
222 .font(.gbSans(.subheadline))
223 }
224 .disabled(model.working || username.isEmpty)
225 .accessibilityIdentifier("account-delete-button")
226 }
227 } footer: {
228 VStack(alignment: .leading, spacing: 4) {
229 Text("Keep a copy first:")
230 Text(verbatim: "ssh git@\(session.current?.instance.baseURL.host() ?? "the instance") account export > bundle.json")
231 .font(.gbMono(.caption))
232 .textSelection(.enabled)
233 }
234 }
235 }
236
237 /// `notifications settings mail` and `watch`. The inbox is filed
238 /// either way, and login links still arrive. Watch lands with
239 /// v1.19.0; an older server shows mail alone.
240 @ViewBuilder
241 private var notificationsSection: some View {
242 if let mailOn = model.mailOn {
243 Section {
244 SettingToggle(title: "Activity by mail", serverValue: mailOn, accepted: {
245 await model.setMail($0)
246 })
247 .disabled(model.working)
248 if let watchOn = model.watchOn {
249 SettingToggle(title: "Watch writable repositories", serverValue: watchOn, accepted: {
250 await model.setWatch($0)
251 })
252 .disabled(model.working)
253 }
254 if model.offersReply, let replyOn = model.replyOn {
255 SettingToggle(title: "Reply by mail", serverValue: replyOn, accepted: {
256 await model.setReply($0)
257 })
258 .disabled(model.working)
259 }
260 } header: {
261 Text("Notifications")
262 } footer: {
263 Text("Mail off keeps the inbox and stops the mail; login links are not activity and still arrive. Watching makes you a recipient of every issue and merge request on repositories you can write to."
264 + (model.offersReply ? " With reply by mail, answering issue and merge request mail from a verified address posts your reply as a comment." : ""))
265 }
266 }
267 }
268
269 private func sshSection(_ keys: [AccountViewModel.SSHKey]) -> some View {
270 Section {
271 ForEach(keys) { key in
272 VStack(alignment: .leading, spacing: 2) {
273 if let label = key.label, !label.isEmpty {
274 Text(label)
275 .font(.gbSans(.subheadline).weight(.medium))
276 }
277 Text(key.fingerprint)
278 .font(.gbMono(.caption))
279 .lineLimit(1)
280 .truncationMode(.middle)
281 HStack(spacing: 6) {
282 Text(key.algo)
283 GBChip(key.scope, .secondary)
284 if let by = key.createdBy {
285 Text("added by \(by)")
286 }
287 }
288 .font(.gbSans(.caption2))
289 .foregroundStyle(.secondary)
290 keyDates(key)
291 }
292 .swipeActions {
293 Button("Remove", role: .destructive) {
294 sshConfirmText = ""
295 removingSSH = key
296 }
297 Button("Label") {
298 sshLabelText = key.label ?? ""
299 labellingSSH = key
300 }
301 .tint(.gbAccent)
302 }
303 }
304 Button {
305 addingSSH = true
306 } label: {
307 Label("Add SSH Key", systemImage: "plus")
308 .font(.gbSans(.subheadline))
309 }
310 .accessibilityIdentifier("add-ssh-key")
311 } header: {
312 Text("SSH keys")
313 } footer: {
314 Text("A git-scoped key can push and pull but not run account commands.")
315 }
316 }
317
318 /// When the key was last used and when it stops working, as the CLI's
319 /// key list says it.
320 private func keyDates(_ key: AccountViewModel.SSHKey) -> some View {
321 HStack(spacing: 4) {
322 if let used = key.lastUsedAt {
323 Text("used \(used.formatted(.relative(presentation: .named)))")
324 } else {
325 Text("never used")
326 }
327 if let expires = key.expiresAt {
328 Text("·")
329 if key.isExpired(at: .now) {
330 Text("expired \(expires.formatted(date: .abbreviated, time: .omitted))")
331 .foregroundStyle(Color.gbBad)
332 } else {
333 Text("expires \(expires.formatted(date: .abbreviated, time: .omitted))")
334 }
335 }
336 }
337 .font(.gbSans(.caption2))
338 .foregroundStyle(.secondary)
339 }
340
341 private func pgpSection(_ keys: [AccountViewModel.PGPKey]) -> some View {
342 Section {
343 ForEach(keys) { key in
344 VStack(alignment: .leading, spacing: 2) {
345 Text(key.fingerprint)
346 .font(.gbMono(.caption))
347 .lineLimit(1)
348 .truncationMode(.middle)
349 if !key.emailList.isEmpty {
350 Text(key.emailList.joined(separator: ", "))
351 .font(.gbSans(.caption2))
352 .foregroundStyle(.secondary)
353 }
354 }
355 .swipeActions {
356 Button("Remove", role: .destructive) {
357 removingPGP = key
358 }
359 }
360 }
361 Button {
362 addingPGP = true
363 } label: {
364 Label("Add PGP Key", systemImage: "plus")
365 .font(.gbSans(.subheadline))
366 }
367 .accessibilityIdentifier("add-pgp-key")
368 } header: {
369 Text("PGP keys")
370 } footer: {
371 Text("Signed commits verify against these; the badge on the log names the state.")
372 }
373 }
374
375 private var emailSection: some View {
376 Section {
377 ForEach(model.emails ?? []) { email in
378 HStack {
379 Text(email.address)
380 .font(.gbSans(.subheadline))
381 .lineLimit(1)
382 Spacer()
383 if email.primary {
384 GBChip("primary", .secondary)
385 }
386 GBChip(email.verified ? "verified" : "unverified", email.verified ? .gbDone : .gbWarn)
387 }
388 .swipeActions {
389 if !email.primary {
390 Button("Remove", role: .destructive) {
391 removingEmail = email
392 }
393 .disabled(model.working)
394 }
395 if email.verified && !email.primary {
396 Button("Make primary") {
397 Task { await model.setPrimaryEmail(email.address) }
398 }
399 .disabled(model.working)
400 }
401 }
402 }
403 HStack {
404 TextField("Add email address", text: $emailAddress)
405 .keyboardType(.emailAddress)
406 .autocorrectionDisabled()
407 .textInputAutocapitalization(.never)
408 .accessibilityIdentifier("email-address")
409 Button("Add") {
410 let address = emailAddress
411 emailAddress = ""
412 Task { await model.addEmail(address) }
413 }
414 .font(.gbSans(.caption))
415 .disabled(!emailAddress.contains("@") || model.working)
416 }
417 HStack {
418 TextField("Verification code", text: $verifyCode)
419 .autocorrectionDisabled()
420 .textInputAutocapitalization(.never)
421 .accessibilityIdentifier("email-code")
422 Button("Verify") {
423 let code = verifyCode
424 verifyCode = ""
425 Task { await model.verifyEmail(code: code) }
426 }
427 .font(.gbSans(.caption))
428 .disabled(verifyCode.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
429 .accessibilityIdentifier("email-verify")
430 }
431 } header: {
432 Text("Email")
433 } footer: {
434 Text("Adding an address mails a code; commits carry your verified identity. The primary and the last verified address cannot be removed.")
435 }
436 }
437}
438
439/// Paste-a-key sheet shared by SSH and PGP. Public keys are not secrets.
440private struct KeyPasteSheet: View {
441
442 let heading: String
443 let prompt: String
444 let scopes: [String]?
445 let working: Bool
446 let errorMessage: String?
447 /// Text, scope (nil without scopes), label (empty without scopes),
448 /// and the key's lifetime (nil when it does not expire, or without scopes).
449 let onSubmit: (String, String?, String, String?) -> Void
450
451 @Environment(\.dismiss) private var dismiss
452 @State private var text = ""
453 @State private var scope = "full"
454 @State private var label = ""
455 @State private var ttl: String?
456
457 var body: some View {
458 NavigationStack {
459 Form {
460 Section {
461 TextEditor(text: $text)
462 .frame(minHeight: 120)
463 .font(.gbMono(.caption))
464 .autocorrectionDisabled()
465 .textInputAutocapitalization(.never)
466 .accessibilityIdentifier("key-paste-text")
467 } footer: {
468 Text(prompt)
469 }
470 if let scopes {
471 Section {
472 Picker("Scope", selection: $scope) {
473 ForEach(scopes, id: \.self) { Text($0).tag($0) }
474 }
475 .pickerStyle(.segmented)
476 TextField("Label (optional)", text: $label)
477 .autocorrectionDisabled()
478 .textInputAutocapitalization(.never)
479 .accessibilityIdentifier("key-paste-label")
480 Picker("Expires", selection: $ttl) {
481 Text("Never").tag(String?.none)
482 Text("In 30 days").tag(String?.some("30d"))
483 Text("In 90 days").tag(String?.some("90d"))
484 Text("In a year").tag(String?.some("365d"))
485 }
486 .accessibilityIdentifier("key-paste-ttl")
487 }
488 }
489 if let errorMessage {
490 Section {
491 GBNotice(errorMessage)
492 }
493 }
494 }
495 .navigationTitle(heading)
496 .navigationBarTitleDisplayMode(.inline)
497 .toolbar {
498 ToolbarItem(placement: .cancellationAction) {
499 Button("Cancel") { dismiss() }
500 }
501 ToolbarItem(placement: .confirmationAction) {
502 if working {
503 ProgressView()
504 } else {
505 Button("Add") {
506 onSubmit(text, scopes != nil ? scope : nil, scopes != nil ? label : "", scopes != nil ? ttl : nil)
507 }
508 .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
509 .accessibilityIdentifier("key-paste-submit")
510 }
511 }
512 }
513 .interactiveDismissDisabled(working)
514 }
515 }
516}