gitbay/Views/Account/AccountView.swift

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

358 lines · 13370 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 removingEmail: AccountViewModel.EmailAddress?
 13    @State private var removingSSH: AccountViewModel.SSHKey?
 14    @State private var removingPGP: AccountViewModel.PGPKey?
 15
 16    init(client: GitbayClient) {
 17        _model = State(initialValue: AccountViewModel(client: client))
 18    }
 19
 20    var body: some View {
 21        List {
 22            if let loaded = model.state.value {
 23                if !loaded.orgs.isEmpty {
 24                    Section("Organizations") {
 25                        ForEach(loaded.orgs) { membership in
 26                            NavigationLink(value: OrgRoute.org(membership.org)) {
 27                                HStack {
 28                                    Label(membership.org, systemImage: "building.2")
 29                                        .font(.gbSans(.subheadline))
 30                                    Spacer()
 31                                    GBChip(membership.role, .secondary)
 32                                }
 33                            }
 34                        }
 35                    }
 36                }
 37                sshSection(loaded.sshKeys)
 38                pgpSection(loaded.pgpKeys)
 39                notificationsSection
 40                // Feedback sits beside the email actions: with the
 41                // keyboard up, a banner at the top of the list is
 42                // scrolled out of existence.
 43                if let error = model.actionError {
 44                    Section {
 45                        GBNotice(error, .gbWarn)
 46                    }
 47                }
 48                if let notice = model.notice {
 49                    Section {
 50                        GBNotice(notice, .gbOK)
 51                    }
 52                }
 53                emailSection
 54            }
 55        }
 56        .overlay { LoadStateOverlay(state: model.state) }
 57        .navigationTitle("Account")
 58        .navigationBarTitleDisplayMode(.inline)
 59        .task { await model.load() }
 60        .task { await model.loadMailPreference() }
 61        .task { await model.loadEmails() }
 62        .confirmationDialog(
 63            "Remove \(removingEmail?.address ?? "")?",
 64            isPresented: Binding(
 65                get: { removingEmail != nil },
 66                set: { if !$0 { removingEmail = nil } }
 67            ),
 68            titleVisibility: .visible
 69        ) {
 70            Button("Remove", role: .destructive) {
 71                if let email = removingEmail {
 72                    Task { await model.removeEmail(email.address) }
 73                }
 74                removingEmail = nil
 75            }
 76            Button("Cancel", role: .cancel) {}
 77        }
 78        .refreshable {
 79            await model.load()
 80            await model.loadMailPreference()
 81            await model.loadEmails()
 82        }
 83        .sheet(isPresented: $addingSSH) {
 84            KeyPasteSheet(
 85                heading: "Add SSH Key",
 86                prompt: "Paste an authorized_keys line (ssh-ed25519 AAAA…)",
 87                scopes: ["full", "git"],
 88                working: model.working,
 89                errorMessage: model.actionError
 90            ) { text, scope in
 91                Task {
 92                    await model.addSSHKey(text, scope: scope ?? "full")
 93                    if model.actionError == nil { addingSSH = false }
 94                }
 95            }
 96        }
 97        .sheet(isPresented: $addingPGP) {
 98            KeyPasteSheet(
 99                heading: "Add PGP Key",
100                prompt: "Paste an armored public key (-----BEGIN PGP PUBLIC KEY BLOCK-----)",
101                scopes: nil,
102                working: model.working,
103                errorMessage: model.actionError
104            ) { text, _ in
105                Task {
106                    await model.addPGPKey(text)
107                    if model.actionError == nil { addingPGP = false }
108                }
109            }
110        }
111        .confirmationDialog(
112            "Remove this SSH key? Anything authenticating with it loses access.",
113            isPresented: Binding(
114                get: { removingSSH != nil },
115                set: { if !$0 { removingSSH = nil } }
116            )
117        ) {
118            Button("Remove", role: .destructive) {
119                if let key = removingSSH {
120                    Task { await model.removeSSHKey(key) }
121                }
122                removingSSH = nil
123            }
124            Button("Cancel", role: .cancel) {}
125        }
126        .confirmationDialog(
127            "Remove this PGP key? Commits it signed become unverifiable.",
128            isPresented: Binding(
129                get: { removingPGP != nil },
130                set: { if !$0 { removingPGP = nil } }
131            )
132        ) {
133            Button("Remove", role: .destructive) {
134                if let key = removingPGP {
135                    Task { await model.removePGPKey(key) }
136                }
137                removingPGP = nil
138            }
139            Button("Cancel", role: .cancel) {}
140        }
141    }
142
143    // MARK: - Sections
144
145    /// `notifications settings mail`: the mail half of notifications. The
146    /// inbox is filed either way, and login links still arrive.
147    @ViewBuilder
148    private var notificationsSection: some View {
149        if let mailOn = model.mailOn {
150            Section {
151                SettingToggle(title: "Activity by mail", serverValue: mailOn) {
152                    await model.setMail($0)
153                }
154                .disabled(model.working)
155            } header: {
156                Text("Notifications")
157            } footer: {
158                Text("Off keeps the inbox and stops the mail. Login links are not activity and still arrive.")
159            }
160        }
161    }
162
163    private func sshSection(_ keys: [AccountViewModel.SSHKey]) -> some View {
164        Section {
165            ForEach(keys) { key in
166                VStack(alignment: .leading, spacing: 2) {
167                    Text(key.fingerprint)
168                        .font(.gbMono(.caption))
169                        .lineLimit(1)
170                        .truncationMode(.middle)
171                    HStack(spacing: 6) {
172                        Text(key.algo)
173                        GBChip(key.scope, .secondary)
174                    }
175                    .font(.gbSans(.caption2))
176                    .foregroundStyle(.secondary)
177                }
178                .swipeActions {
179                    Button("Remove", role: .destructive) {
180                        removingSSH = key
181                    }
182                }
183            }
184            Button {
185                addingSSH = true
186            } label: {
187                Label("Add SSH Key", systemImage: "plus")
188                    .font(.gbSans(.subheadline))
189            }
190            .accessibilityIdentifier("add-ssh-key")
191        } header: {
192            Text("SSH keys")
193        } footer: {
194            Text("A git-scoped key can push and pull but not run account commands.")
195        }
196    }
197
198    private func pgpSection(_ keys: [AccountViewModel.PGPKey]) -> some View {
199        Section {
200            ForEach(keys) { key in
201                VStack(alignment: .leading, spacing: 2) {
202                    Text(key.fingerprint)
203                        .font(.gbMono(.caption))
204                        .lineLimit(1)
205                        .truncationMode(.middle)
206                    if !key.emailList.isEmpty {
207                        Text(key.emailList.joined(separator: ", "))
208                            .font(.gbSans(.caption2))
209                            .foregroundStyle(.secondary)
210                    }
211                }
212                .swipeActions {
213                    Button("Remove", role: .destructive) {
214                        removingPGP = key
215                    }
216                }
217            }
218            Button {
219                addingPGP = true
220            } label: {
221                Label("Add PGP Key", systemImage: "plus")
222                    .font(.gbSans(.subheadline))
223            }
224            .accessibilityIdentifier("add-pgp-key")
225        } header: {
226            Text("PGP keys")
227        } footer: {
228            Text("Signed commits verify against these; the badge on the log names the state.")
229        }
230    }
231
232    private var emailSection: some View {
233        Section {
234            ForEach(model.emails ?? []) { email in
235                HStack {
236                    Text(email.address)
237                        .font(.gbSans(.subheadline))
238                        .lineLimit(1)
239                    Spacer()
240                    if email.primary {
241                        GBChip("primary", .secondary)
242                    }
243                    GBChip(email.verified ? "verified" : "unverified", email.verified ? .gbDone : .gbWarn)
244                }
245                .swipeActions {
246                    if !email.primary {
247                        Button("Remove", role: .destructive) {
248                            removingEmail = email
249                        }
250                        .disabled(model.working)
251                    }
252                    if email.verified && !email.primary {
253                        Button("Make primary") {
254                            Task { await model.setPrimaryEmail(email.address) }
255                        }
256                        .disabled(model.working)
257                    }
258                }
259            }
260            HStack {
261                TextField("Add email address", text: $emailAddress)
262                    .keyboardType(.emailAddress)
263                    .autocorrectionDisabled()
264                    .textInputAutocapitalization(.never)
265                    .accessibilityIdentifier("email-address")
266                Button("Add") {
267                    let address = emailAddress
268                    emailAddress = ""
269                    Task { await model.addEmail(address) }
270                }
271                .font(.gbSans(.caption))
272                .disabled(!emailAddress.contains("@") || model.working)
273            }
274            HStack {
275                TextField("Verification code", text: $verifyCode)
276                    .autocorrectionDisabled()
277                    .textInputAutocapitalization(.never)
278                    .accessibilityIdentifier("email-code")
279                Button("Verify") {
280                    let code = verifyCode
281                    verifyCode = ""
282                    Task { await model.verifyEmail(code: code) }
283                }
284                .font(.gbSans(.caption))
285                .disabled(verifyCode.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
286                .accessibilityIdentifier("email-verify")
287            }
288        } header: {
289            Text("Email")
290        } footer: {
291            Text("Adding an address mails a code; commits carry your verified identity. The primary and the last verified address cannot be removed.")
292        }
293    }
294}
295
296/// Paste-a-key sheet shared by SSH and PGP. Public keys are not secrets.
297private struct KeyPasteSheet: View {
298
299    let heading: String
300    let prompt: String
301    let scopes: [String]?
302    let working: Bool
303    let errorMessage: String?
304    let onSubmit: (String, String?) -> Void
305
306    @Environment(\.dismiss) private var dismiss
307    @State private var text = ""
308    @State private var scope = "full"
309
310    var body: some View {
311        NavigationStack {
312            Form {
313                Section {
314                    TextEditor(text: $text)
315                        .frame(minHeight: 120)
316                        .font(.gbMono(.caption))
317                        .autocorrectionDisabled()
318                        .textInputAutocapitalization(.never)
319                        .accessibilityIdentifier("key-paste-text")
320                } footer: {
321                    Text(prompt)
322                }
323                if let scopes {
324                    Section {
325                        Picker("Scope", selection: $scope) {
326                            ForEach(scopes, id: \.self) { Text($0).tag($0) }
327                        }
328                        .pickerStyle(.segmented)
329                    }
330                }
331                if let errorMessage {
332                    Section {
333                        GBNotice(errorMessage)
334                    }
335                }
336            }
337            .navigationTitle(heading)
338            .navigationBarTitleDisplayMode(.inline)
339            .toolbar {
340                ToolbarItem(placement: .cancellationAction) {
341                    Button("Cancel") { dismiss() }
342                }
343                ToolbarItem(placement: .confirmationAction) {
344                    if working {
345                        ProgressView()
346                    } else {
347                        Button("Add") {
348                            onSubmit(text, scopes != nil ? scope : nil)
349                        }
350                        .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
351                        .accessibilityIdentifier("key-paste-submit")
352                    }
353                }
354            }
355            .interactiveDismissDisabled(working)
356        }
357    }
358}