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