import SwiftUI /// SSH keys, PGP keys, and email verification. Tokens are minted over /// SSH only, by design — no UI implies otherwise. struct AccountView: View { @State private var model: AccountViewModel @State private var addingSSH = false @State private var addingPGP = false @State private var emailAddress = "" @State private var verifyCode = "" @State private var removingSSH: AccountViewModel.SSHKey? @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) // 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 } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Account") .navigationBarTitleDisplayMode(.inline) .task { await model.load() } .refreshable { await model.load() } .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 in Task { await model.addSSHKey(text, scope: scope ?? "full") 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 } } } } .confirmationDialog( "Remove this SSH key? Anything authenticating with it loses access.", isPresented: Binding( get: { removingSSH != nil }, set: { if !$0 { removingSSH = nil } } ) ) { Button("Remove", role: .destructive) { if let key = removingSSH { Task { await model.removeSSHKey(key) } } removingSSH = nil } Button("Cancel", role: .cancel) {} } .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 func sshSection(_ keys: [AccountViewModel.SSHKey]) -> some View { Section { ForEach(keys) { key in VStack(alignment: .leading, spacing: 2) { Text(key.fingerprint) .font(.gbMono(.caption)) .lineLimit(1) .truncationMode(.middle) HStack(spacing: 6) { Text(key.algo) GBChip(key.scope, .secondary) } .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } .swipeActions { Button("Remove", role: .destructive) { removingSSH = key } } } 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.") } } 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 { 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.") } } } /// 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? let onSubmit: (String, String?) -> Void @Environment(\.dismiss) private var dismiss @State private var text = "" @State private var scope = "full" 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) } } 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) } .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .accessibilityIdentifier("key-paste-submit") } } } .interactiveDismissDisabled(working) } } }