gitbay/Views/Account/AccountView.swift

5411753023a5e2958f17b523903abce380941107
gitbay-ios/gitbay/Views/Account/AccountView.swift history · blame · raw

303 lines · 11409 bytes

  1import SwiftUI
  2
  3/// SSH keys, PGP keys, and email verification. Tokens are minted over
  4/// SSH only, by design — no UI implies otherwise.
  5struct AccountView: View {
  6
  7    @State private var model: AccountViewModel
  8    @State private var addingSSH = false
  9    @State private var addingPGP = false
 10    @State private var emailAddress = ""
 11    @State private var verifyCode = ""
 12    @State private var removingSSH: AccountViewModel.SSHKey?
 13    @State private var removingPGP: AccountViewModel.PGPKey?
 14
 15    init(client: GitbayClient) {
 16        _model = State(initialValue: AccountViewModel(client: client))
 17    }
 18
 19    var body: some View {
 20        List {
 21            if let loaded = model.state.value {
 22                if !loaded.orgs.isEmpty {
 23                    Section("Organizations") {
 24                        ForEach(loaded.orgs) { membership in
 25                            NavigationLink(value: OrgRoute.org(membership.org)) {
 26                                HStack {
 27                                    Label(membership.org, systemImage: "building.2")
 28                                        .font(.subheadline)
 29                                    Spacer()
 30                                    Text(membership.role)
 31                                        .font(.caption)
 32                                        .padding(.horizontal, 6)
 33                                        .padding(.vertical, 2)
 34                                        .background(.quaternary, in: Capsule())
 35                                }
 36                            }
 37                        }
 38                    }
 39                }
 40                sshSection(loaded.sshKeys)
 41                pgpSection(loaded.pgpKeys)
 42                // Feedback sits beside the email actions: with the
 43                // keyboard up, a banner at the top of the list is
 44                // scrolled out of existence.
 45                if let error = model.actionError {
 46                    Section {
 47                        Label(error, systemImage: "hand.raised")
 48                            .foregroundStyle(.orange)
 49                            .font(.subheadline)
 50                    }
 51                }
 52                if let notice = model.notice {
 53                    Section {
 54                        Label(notice, systemImage: "envelope")
 55                            .foregroundStyle(.secondary)
 56                            .font(.subheadline)
 57                    }
 58                }
 59                emailSection
 60            }
 61        }
 62        .overlay { LoadStateOverlay(state: model.state) }
 63        .navigationTitle("Account")
 64        .navigationBarTitleDisplayMode(.inline)
 65        .task { await model.load() }
 66        .refreshable { await model.load() }
 67        .sheet(isPresented: $addingSSH) {
 68            KeyPasteSheet(
 69                heading: "Add SSH Key",
 70                prompt: "Paste an authorized_keys line (ssh-ed25519 AAAA…)",
 71                scopes: ["full", "git"],
 72                working: model.working,
 73                errorMessage: model.actionError
 74            ) { text, scope in
 75                Task {
 76                    await model.addSSHKey(text, scope: scope ?? "full")
 77                    if model.actionError == nil { addingSSH = false }
 78                }
 79            }
 80        }
 81        .sheet(isPresented: $addingPGP) {
 82            KeyPasteSheet(
 83                heading: "Add PGP Key",
 84                prompt: "Paste an armored public key (-----BEGIN PGP PUBLIC KEY BLOCK-----)",
 85                scopes: nil,
 86                working: model.working,
 87                errorMessage: model.actionError
 88            ) { text, _ in
 89                Task {
 90                    await model.addPGPKey(text)
 91                    if model.actionError == nil { addingPGP = false }
 92                }
 93            }
 94        }
 95        .confirmationDialog(
 96            "Remove this SSH key? Anything authenticating with it loses access.",
 97            isPresented: Binding(
 98                get: { removingSSH != nil },
 99                set: { if !$0 { removingSSH = nil } }
100            )
101        ) {
102            Button("Remove", role: .destructive) {
103                if let key = removingSSH {
104                    Task { await model.removeSSHKey(key) }
105                }
106                removingSSH = nil
107            }
108            Button("Cancel", role: .cancel) {}
109        }
110        .confirmationDialog(
111            "Remove this PGP key? Commits it signed become unverifiable.",
112            isPresented: Binding(
113                get: { removingPGP != nil },
114                set: { if !$0 { removingPGP = nil } }
115            )
116        ) {
117            Button("Remove", role: .destructive) {
118                if let key = removingPGP {
119                    Task { await model.removePGPKey(key) }
120                }
121                removingPGP = nil
122            }
123            Button("Cancel", role: .cancel) {}
124        }
125    }
126
127    // MARK: - Sections
128
129    private func sshSection(_ keys: [AccountViewModel.SSHKey]) -> some View {
130        Section {
131            ForEach(keys) { key in
132                VStack(alignment: .leading, spacing: 2) {
133                    Text(key.fingerprint)
134                        .font(.caption.monospaced())
135                        .lineLimit(1)
136                        .truncationMode(.middle)
137                    HStack(spacing: 6) {
138                        Text(key.algo)
139                        Text(key.scope)
140                            .padding(.horizontal, 5)
141                            .padding(.vertical, 1)
142                            .background(.quaternary, in: Capsule())
143                    }
144                    .font(.caption2)
145                    .foregroundStyle(.secondary)
146                }
147                .swipeActions {
148                    Button("Remove", role: .destructive) {
149                        removingSSH = key
150                    }
151                }
152            }
153            Button {
154                addingSSH = true
155            } label: {
156                Label("Add SSH Key", systemImage: "plus")
157                    .font(.subheadline)
158            }
159            .accessibilityIdentifier("add-ssh-key")
160        } header: {
161            Text("SSH keys")
162        } footer: {
163            Text("A git-scoped key can push and pull but not run account commands.")
164        }
165    }
166
167    private func pgpSection(_ keys: [AccountViewModel.PGPKey]) -> some View {
168        Section {
169            ForEach(keys) { key in
170                VStack(alignment: .leading, spacing: 2) {
171                    Text(key.fingerprint)
172                        .font(.caption.monospaced())
173                        .lineLimit(1)
174                        .truncationMode(.middle)
175                    if !key.emailList.isEmpty {
176                        Text(key.emailList.joined(separator: ", "))
177                            .font(.caption2)
178                            .foregroundStyle(.secondary)
179                    }
180                }
181                .swipeActions {
182                    Button("Remove", role: .destructive) {
183                        removingPGP = key
184                    }
185                }
186            }
187            Button {
188                addingPGP = true
189            } label: {
190                Label("Add PGP Key", systemImage: "plus")
191                    .font(.subheadline)
192            }
193            .accessibilityIdentifier("add-pgp-key")
194        } header: {
195            Text("PGP keys")
196        } footer: {
197            Text("Signed commits verify against these; the badge on the log names the state.")
198        }
199    }
200
201    private var emailSection: some View {
202        Section {
203            HStack {
204                TextField("Add email address", text: $emailAddress)
205                    .keyboardType(.emailAddress)
206                    .autocorrectionDisabled()
207                    .textInputAutocapitalization(.never)
208                    .accessibilityIdentifier("email-address")
209                Button("Add") {
210                    let address = emailAddress
211                    emailAddress = ""
212                    Task { await model.addEmail(address) }
213                }
214                .font(.caption)
215                .disabled(!emailAddress.contains("@") || model.working)
216            }
217            HStack {
218                TextField("Verification code", text: $verifyCode)
219                    .autocorrectionDisabled()
220                    .textInputAutocapitalization(.never)
221                    .accessibilityIdentifier("email-code")
222                Button("Verify") {
223                    let code = verifyCode
224                    verifyCode = ""
225                    Task { await model.verifyEmail(code: code) }
226                }
227                .font(.caption)
228                .disabled(verifyCode.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
229                .accessibilityIdentifier("email-verify")
230            }
231        } header: {
232            Text("Email")
233        } footer: {
234            Text("Adding an address mails a code; commits carry your verified identity.")
235        }
236    }
237}
238
239/// Paste-a-key sheet shared by SSH and PGP. Public keys are not secrets.
240private struct KeyPasteSheet: View {
241
242    let heading: String
243    let prompt: String
244    let scopes: [String]?
245    let working: Bool
246    let errorMessage: String?
247    let onSubmit: (String, String?) -> Void
248
249    @Environment(\.dismiss) private var dismiss
250    @State private var text = ""
251    @State private var scope = "full"
252
253    var body: some View {
254        NavigationStack {
255            Form {
256                Section {
257                    TextEditor(text: $text)
258                        .frame(minHeight: 120)
259                        .font(.caption.monospaced())
260                        .autocorrectionDisabled()
261                        .textInputAutocapitalization(.never)
262                        .accessibilityIdentifier("key-paste-text")
263                } footer: {
264                    Text(prompt)
265                }
266                if let scopes {
267                    Section {
268                        Picker("Scope", selection: $scope) {
269                            ForEach(scopes, id: \.self) { Text($0).tag($0) }
270                        }
271                        .pickerStyle(.segmented)
272                    }
273                }
274                if let errorMessage {
275                    Section {
276                        Label(errorMessage, systemImage: "exclamationmark.triangle")
277                            .foregroundStyle(.red)
278                            .font(.subheadline)
279                    }
280                }
281            }
282            .navigationTitle(heading)
283            .navigationBarTitleDisplayMode(.inline)
284            .toolbar {
285                ToolbarItem(placement: .cancellationAction) {
286                    Button("Cancel") { dismiss() }
287                }
288                ToolbarItem(placement: .confirmationAction) {
289                    if working {
290                        ProgressView()
291                    } else {
292                        Button("Add") {
293                            onSubmit(text, scopes != nil ? scope : nil)
294                        }
295                        .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
296                        .accessibilityIdentifier("key-paste-submit")
297                    }
298                }
299            }
300            .interactiveDismissDisabled(working)
301        }
302    }
303}