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