gitbay/Views/Account/AccountView.swift

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

408 lines · 15817 bytes

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