a native ios client for gitbay

client ios swift

https://gitbay.org

gitbay/Views/Account/AccountView.swift

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