import SwiftUI /// SSH keys, PGP keys, email verification and notification settings. /// API tokens have their own screen. struct AccountView: View { @Environment(SessionStore.self) private var session @State private var model: AccountViewModel @State private var deletingAccount = false @State private var deleteConfirm = "" @State private var deletionRefusal: String? @State private var addingSSH = false @State private var addingPGP = false @State private var emailAddress = "" @State private var verifyCode = "" @State private var removingEmail: AccountViewModel.EmailAddress? @State private var removingSSH: AccountViewModel.SSHKey? @State private var sshConfirmText = "" @State private var labellingSSH: AccountViewModel.SSHKey? @State private var sshLabelText = "" @State private var removingPGP: AccountViewModel.PGPKey? init(client: GitbayClient) { _model = State(initialValue: AccountViewModel(client: client)) } var body: some View { List { if let loaded = model.state.value { if !loaded.orgs.isEmpty { Section("Organizations") { ForEach(loaded.orgs) { membership in NavigationLink(value: OrgRoute.org(membership.org)) { HStack { Label(membership.org, systemImage: "building.2") .font(.gbSans(.subheadline)) Spacer() GBChip(membership.role, .secondary) } } } } } sshSection(loaded.sshKeys) pgpSection(loaded.pgpKeys) notificationsSection // Feedback sits beside the email actions: with the // keyboard up, a banner at the top of the list is // scrolled out of existence. if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } if let notice = model.notice { Section { GBNotice(notice, .gbOK) } } emailSection deleteAccountSection } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Account") .navigationBarTitleDisplayMode(.inline) .task { await model.load() } .task { await model.loadNotificationPreferences() } .task { await model.loadEmails() } .task { model.loadPendingDeletion(username: username) } .alert("Not deleted", isPresented: Binding(get: { deletionRefusal != nil }, set: { if !$0 { deletionRefusal = nil } })) { Button("OK", role: .cancel) {} } message: { Text(deletionRefusal ?? "") } .alert("Delete your account", isPresented: $deletingAccount) { TextField("type \(username) to confirm", text: $deleteConfirm) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button("Mail the link", role: .destructive) { Task { await model.requestDeletion(username: username) // The list's banner is far above this last row. if model.deletionMailedTo == nil { deletionRefusal = model.actionError } } } .disabled(deleteConfirm != username) Button("Cancel", role: .cancel) {} } message: { 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.") } .confirmationDialog( "Remove \(removingEmail?.address ?? "")?", isPresented: Binding( get: { removingEmail != nil }, set: { if !$0 { removingEmail = nil } } ), titleVisibility: .visible ) { Button("Remove", role: .destructive) { if let email = removingEmail { Task { await model.removeEmail(email.address) } } removingEmail = nil } Button("Cancel", role: .cancel) {} } .refreshable { await model.load() await model.loadNotificationPreferences() await model.loadEmails() } .sheet(isPresented: $addingSSH) { KeyPasteSheet( heading: "Add SSH Key", prompt: "Paste an authorized_keys line (ssh-ed25519 AAAA…)", scopes: ["full", "git"], working: model.working, errorMessage: model.actionError ) { text, scope, label, ttl in Task { await model.addSSHKey(text, scope: scope ?? "full", label: label, ttl: ttl) if model.actionError == nil { addingSSH = false } } } } .sheet(isPresented: $addingPGP) { KeyPasteSheet( heading: "Add PGP Key", prompt: "Paste an armored public key (-----BEGIN PGP PUBLIC KEY BLOCK-----)", scopes: nil, working: model.working, errorMessage: model.actionError ) { text, _, _, _ in Task { await model.addPGPKey(text) if model.actionError == nil { addingPGP = false } } } } .alert( "Label this key", isPresented: Binding( get: { labellingSSH != nil }, set: { if !$0 { labellingSSH = nil } } ), presenting: labellingSSH ) { key in TextField("Label", text: $sshLabelText) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button("Save") { Task { await model.labelSSHKey(key, sshLabelText) } labellingSSH = nil } Button("Cancel", role: .cancel) { labellingSSH = nil } } message: { _ in Text("An empty label clears it.") } .alert( "Remove this SSH key?", isPresented: Binding( get: { removingSSH != nil }, set: { if !$0 { removingSSH = nil } } ), presenting: removingSSH ) { key in TextField("type \(key.confirmation) to confirm", text: $sshConfirmText) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button("Remove", role: .destructive) { Task { await model.removeSSHKey(key) } removingSSH = nil } .disabled(sshConfirmText != key.confirmation) Button("Cancel", role: .cancel) { removingSSH = nil } } message: { key in Text("Anything authenticating with it loses access. Type \(key.confirmation) to confirm.") } .confirmationDialog( "Remove this PGP key? Commits it signed become unverifiable.", isPresented: Binding( get: { removingPGP != nil }, set: { if !$0 { removingPGP = nil } } ) ) { Button("Remove", role: .destructive) { if let key = removingPGP { Task { await model.removePGPKey(key) } } removingPGP = nil } Button("Cancel", role: .cancel) {} } } // MARK: - Sections private var username: String { session.current?.username ?? "" } /// Deleting the account is confirmed by mail, as on the web: nothing /// changes until the link is opened, and signing in during the seven /// days after cancels it. private var deleteAccountSection: some View { Section { if let address = model.deletionMailedTo { Label("A link went to \(address). It works for 24 hours; nothing changes until it is opened.", systemImage: "envelope") .font(.gbSans(.subheadline)) .accessibilityIdentifier("account-delete-mailed") Button("Withdraw the request") { Task { await model.cancelDeletion(username: username) } } .disabled(model.working) .accessibilityIdentifier("account-delete-cancel") } else { Button(role: .destructive) { deleteConfirm = "" deletingAccount = true } label: { Label("Delete account", systemImage: "person.crop.circle.badge.xmark") .font(.gbSans(.subheadline)) } .disabled(model.working || username.isEmpty) .accessibilityIdentifier("account-delete-button") } } footer: { VStack(alignment: .leading, spacing: 4) { Text("Keep a copy first:") Text(verbatim: "ssh git@\(session.current?.instance.baseURL.host() ?? "the instance") account export > bundle.json") .font(.gbMono(.caption)) .textSelection(.enabled) } } } /// `notifications settings mail` and `watch`. The inbox is filed /// either way, and login links still arrive. Watch lands with /// v1.19.0; an older server shows mail alone. @ViewBuilder private var notificationsSection: some View { if let mailOn = model.mailOn { Section { SettingToggle(title: "Activity by mail", serverValue: mailOn, accepted: { await model.setMail($0) }) .disabled(model.working) if let watchOn = model.watchOn { SettingToggle(title: "Watch writable repositories", serverValue: watchOn, accepted: { await model.setWatch($0) }) .disabled(model.working) } if model.offersReply, let replyOn = model.replyOn { SettingToggle(title: "Reply by mail", serverValue: replyOn, accepted: { await model.setReply($0) }) .disabled(model.working) } } header: { Text("Notifications") } footer: { 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." + (model.offersReply ? " With reply by mail, answering issue and merge request mail from a verified address posts your reply as a comment." : "")) } } } private func sshSection(_ keys: [AccountViewModel.SSHKey]) -> some View { Section { ForEach(keys) { key in VStack(alignment: .leading, spacing: 2) { if let label = key.label, !label.isEmpty { Text(label) .font(.gbSans(.subheadline).weight(.medium)) } Text(key.fingerprint) .font(.gbMono(.caption)) .lineLimit(1) .truncationMode(.middle) HStack(spacing: 6) { Text(key.algo) GBChip(key.scope, .secondary) if let by = key.createdBy { Text("added by \(by)") } } .font(.gbSans(.caption2)) .foregroundStyle(.secondary) keyDates(key) } .swipeActions { Button("Remove", role: .destructive) { sshConfirmText = "" removingSSH = key } Button("Label") { sshLabelText = key.label ?? "" labellingSSH = key } .tint(.gbAccent) } } Button { addingSSH = true } label: { Label("Add SSH Key", systemImage: "plus") .font(.gbSans(.subheadline)) } .accessibilityIdentifier("add-ssh-key") } header: { Text("SSH keys") } footer: { Text("A git-scoped key can push and pull but not run account commands.") } } /// When the key was last used and when it stops working, as the CLI's /// key list says it. private func keyDates(_ key: AccountViewModel.SSHKey) -> some View { HStack(spacing: 4) { if let used = key.lastUsedAt { Text("used \(used.formatted(.relative(presentation: .named)))") } else { Text("never used") } if let expires = key.expiresAt { Text("·") if key.isExpired(at: .now) { Text("expired \(expires.formatted(date: .abbreviated, time: .omitted))") .foregroundStyle(Color.gbBad) } else { Text("expires \(expires.formatted(date: .abbreviated, time: .omitted))") } } } .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } private func pgpSection(_ keys: [AccountViewModel.PGPKey]) -> some View { Section { ForEach(keys) { key in VStack(alignment: .leading, spacing: 2) { Text(key.fingerprint) .font(.gbMono(.caption)) .lineLimit(1) .truncationMode(.middle) if !key.emailList.isEmpty { Text(key.emailList.joined(separator: ", ")) .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } } .swipeActions { Button("Remove", role: .destructive) { removingPGP = key } } } Button { addingPGP = true } label: { Label("Add PGP Key", systemImage: "plus") .font(.gbSans(.subheadline)) } .accessibilityIdentifier("add-pgp-key") } header: { Text("PGP keys") } footer: { Text("Signed commits verify against these; the badge on the log names the state.") } } private var emailSection: some View { Section { ForEach(model.emails ?? []) { email in HStack { Text(email.address) .font(.gbSans(.subheadline)) .lineLimit(1) Spacer() if email.primary { GBChip("primary", .secondary) } GBChip(email.verified ? "verified" : "unverified", email.verified ? .gbDone : .gbWarn) } .swipeActions { if !email.primary { Button("Remove", role: .destructive) { removingEmail = email } .disabled(model.working) } if email.verified && !email.primary { Button("Make primary") { Task { await model.setPrimaryEmail(email.address) } } .disabled(model.working) } } } HStack { TextField("Add email address", text: $emailAddress) .keyboardType(.emailAddress) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("email-address") Button("Add") { let address = emailAddress emailAddress = "" Task { await model.addEmail(address) } } .font(.gbSans(.caption)) .disabled(!emailAddress.contains("@") || model.working) } HStack { TextField("Verification code", text: $verifyCode) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("email-code") Button("Verify") { let code = verifyCode verifyCode = "" Task { await model.verifyEmail(code: code) } } .font(.gbSans(.caption)) .disabled(verifyCode.trimmingCharacters(in: .whitespaces).isEmpty || model.working) .accessibilityIdentifier("email-verify") } } header: { Text("Email") } footer: { Text("Adding an address mails a code; commits carry your verified identity. The primary and the last verified address cannot be removed.") } } } /// Paste-a-key sheet shared by SSH and PGP. Public keys are not secrets. private struct KeyPasteSheet: View { let heading: String let prompt: String let scopes: [String]? let working: Bool let errorMessage: String? /// Text, scope (nil without scopes), label (empty without scopes), /// and the key's lifetime (nil when it does not expire, or without scopes). let onSubmit: (String, String?, String, String?) -> Void @Environment(\.dismiss) private var dismiss @State private var text = "" @State private var scope = "full" @State private var label = "" @State private var ttl: String? var body: some View { NavigationStack { Form { Section { TextEditor(text: $text) .frame(minHeight: 120) .font(.gbMono(.caption)) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("key-paste-text") } footer: { Text(prompt) } if let scopes { Section { Picker("Scope", selection: $scope) { ForEach(scopes, id: \.self) { Text($0).tag($0) } } .pickerStyle(.segmented) TextField("Label (optional)", text: $label) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("key-paste-label") Picker("Expires", selection: $ttl) { Text("Never").tag(String?.none) Text("In 30 days").tag(String?.some("30d")) Text("In 90 days").tag(String?.some("90d")) Text("In a year").tag(String?.some("365d")) } .accessibilityIdentifier("key-paste-ttl") } } if let errorMessage { Section { GBNotice(errorMessage) } } } .navigationTitle(heading) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { if working { ProgressView() } else { Button("Add") { onSubmit(text, scopes != nil ? scope : nil, scopes != nil ? label : "", scopes != nil ? ttl : nil) } .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .accessibilityIdentifier("key-paste-submit") } } } .interactiveDismissDisabled(working) } } }