gitbay/Views/Account/AccountView.swift

v1.14.1
gitbay-ios/gitbay/Views/Account/AccountView.swift history · blame · raw

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}