gitbay/Views/Account/AccountView.swift
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}