gitbay/Views/Account/AccountView.swift

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

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