gitbay/Views/Account/AccountView.swift

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

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}