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