gitbay/Views/Account/AccountView.swift

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

365 lines · 13818 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.loadNotificationPreferences() }
 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.loadNotificationPreferences()
 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` and `watch`. The inbox is filed
146    /// either way, and login links still arrive. Watch lands with
147    /// v1.19.0; an older server shows mail alone.
148    @ViewBuilder
149    private var notificationsSection: some View {
150        if let mailOn = model.mailOn {
151            Section {
152                SettingToggle(title: "Activity by mail", serverValue: mailOn) {
153                    await model.setMail($0)
154                }
155                .disabled(model.working)
156                if let watchOn = model.watchOn {
157                    SettingToggle(title: "Watch writable repositories", serverValue: watchOn) {
158                        await model.setWatch($0)
159                    }
160                    .disabled(model.working)
161                }
162            } header: {
163                Text("Notifications")
164            } footer: {
165                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.")
166            }
167        }
168    }
169
170    private func sshSection(_ keys: [AccountViewModel.SSHKey]) -> some View {
171        Section {
172            ForEach(keys) { key in
173                VStack(alignment: .leading, spacing: 2) {
174                    Text(key.fingerprint)
175                        .font(.gbMono(.caption))
176                        .lineLimit(1)
177                        .truncationMode(.middle)
178                    HStack(spacing: 6) {
179                        Text(key.algo)
180                        GBChip(key.scope, .secondary)
181                    }
182                    .font(.gbSans(.caption2))
183                    .foregroundStyle(.secondary)
184                }
185                .swipeActions {
186                    Button("Remove", role: .destructive) {
187                        removingSSH = key
188                    }
189                }
190            }
191            Button {
192                addingSSH = true
193            } label: {
194                Label("Add SSH Key", systemImage: "plus")
195                    .font(.gbSans(.subheadline))
196            }
197            .accessibilityIdentifier("add-ssh-key")
198        } header: {
199            Text("SSH keys")
200        } footer: {
201            Text("A git-scoped key can push and pull but not run account commands.")
202        }
203    }
204
205    private func pgpSection(_ keys: [AccountViewModel.PGPKey]) -> some View {
206        Section {
207            ForEach(keys) { key in
208                VStack(alignment: .leading, spacing: 2) {
209                    Text(key.fingerprint)
210                        .font(.gbMono(.caption))
211                        .lineLimit(1)
212                        .truncationMode(.middle)
213                    if !key.emailList.isEmpty {
214                        Text(key.emailList.joined(separator: ", "))
215                            .font(.gbSans(.caption2))
216                            .foregroundStyle(.secondary)
217                    }
218                }
219                .swipeActions {
220                    Button("Remove", role: .destructive) {
221                        removingPGP = key
222                    }
223                }
224            }
225            Button {
226                addingPGP = true
227            } label: {
228                Label("Add PGP Key", systemImage: "plus")
229                    .font(.gbSans(.subheadline))
230            }
231            .accessibilityIdentifier("add-pgp-key")
232        } header: {
233            Text("PGP keys")
234        } footer: {
235            Text("Signed commits verify against these; the badge on the log names the state.")
236        }
237    }
238
239    private var emailSection: some View {
240        Section {
241            ForEach(model.emails ?? []) { email in
242                HStack {
243                    Text(email.address)
244                        .font(.gbSans(.subheadline))
245                        .lineLimit(1)
246                    Spacer()
247                    if email.primary {
248                        GBChip("primary", .secondary)
249                    }
250                    GBChip(email.verified ? "verified" : "unverified", email.verified ? .gbDone : .gbWarn)
251                }
252                .swipeActions {
253                    if !email.primary {
254                        Button("Remove", role: .destructive) {
255                            removingEmail = email
256                        }
257                        .disabled(model.working)
258                    }
259                    if email.verified && !email.primary {
260                        Button("Make primary") {
261                            Task { await model.setPrimaryEmail(email.address) }
262                        }
263                        .disabled(model.working)
264                    }
265                }
266            }
267            HStack {
268                TextField("Add email address", text: $emailAddress)
269                    .keyboardType(.emailAddress)
270                    .autocorrectionDisabled()
271                    .textInputAutocapitalization(.never)
272                    .accessibilityIdentifier("email-address")
273                Button("Add") {
274                    let address = emailAddress
275                    emailAddress = ""
276                    Task { await model.addEmail(address) }
277                }
278                .font(.gbSans(.caption))
279                .disabled(!emailAddress.contains("@") || model.working)
280            }
281            HStack {
282                TextField("Verification code", text: $verifyCode)
283                    .autocorrectionDisabled()
284                    .textInputAutocapitalization(.never)
285                    .accessibilityIdentifier("email-code")
286                Button("Verify") {
287                    let code = verifyCode
288                    verifyCode = ""
289                    Task { await model.verifyEmail(code: code) }
290                }
291                .font(.gbSans(.caption))
292                .disabled(verifyCode.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
293                .accessibilityIdentifier("email-verify")
294            }
295        } header: {
296            Text("Email")
297        } footer: {
298            Text("Adding an address mails a code; commits carry your verified identity. The primary and the last verified address cannot be removed.")
299        }
300    }
301}
302
303/// Paste-a-key sheet shared by SSH and PGP. Public keys are not secrets.
304private struct KeyPasteSheet: View {
305
306    let heading: String
307    let prompt: String
308    let scopes: [String]?
309    let working: Bool
310    let errorMessage: String?
311    let onSubmit: (String, String?) -> Void
312
313    @Environment(\.dismiss) private var dismiss
314    @State private var text = ""
315    @State private var scope = "full"
316
317    var body: some View {
318        NavigationStack {
319            Form {
320                Section {
321                    TextEditor(text: $text)
322                        .frame(minHeight: 120)
323                        .font(.gbMono(.caption))
324                        .autocorrectionDisabled()
325                        .textInputAutocapitalization(.never)
326                        .accessibilityIdentifier("key-paste-text")
327                } footer: {
328                    Text(prompt)
329                }
330                if let scopes {
331                    Section {
332                        Picker("Scope", selection: $scope) {
333                            ForEach(scopes, id: \.self) { Text($0).tag($0) }
334                        }
335                        .pickerStyle(.segmented)
336                    }
337                }
338                if let errorMessage {
339                    Section {
340                        GBNotice(errorMessage)
341                    }
342                }
343            }
344            .navigationTitle(heading)
345            .navigationBarTitleDisplayMode(.inline)
346            .toolbar {
347                ToolbarItem(placement: .cancellationAction) {
348                    Button("Cancel") { dismiss() }
349                }
350                ToolbarItem(placement: .confirmationAction) {
351                    if working {
352                        ProgressView()
353                    } else {
354                        Button("Add") {
355                            onSubmit(text, scopes != nil ? scope : nil)
356                        }
357                        .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
358                        .accessibilityIdentifier("key-paste-submit")
359                    }
360                }
361            }
362            .interactiveDismissDisabled(working)
363        }
364    }
365}