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