gitbay/Views/Account/AccountView.swift
290 lines · 10677 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 removingSSH: AccountViewModel.SSHKey?
13 @State private var removingPGP: AccountViewModel.PGPKey?
14
15 init(client: GitbayClient) {
16 _model = State(initialValue: AccountViewModel(client: client))
17 }
18
19 var body: some View {
20 List {
21 if let loaded = model.state.value {
22 if !loaded.orgs.isEmpty {
23 Section("Organizations") {
24 ForEach(loaded.orgs) { membership in
25 NavigationLink(value: OrgRoute.org(membership.org)) {
26 HStack {
27 Label(membership.org, systemImage: "building.2")
28 .font(.gbSans(.subheadline))
29 Spacer()
30 GBChip(membership.role, .secondary)
31 }
32 }
33 }
34 }
35 }
36 sshSection(loaded.sshKeys)
37 pgpSection(loaded.pgpKeys)
38 // Feedback sits beside the email actions: with the
39 // keyboard up, a banner at the top of the list is
40 // scrolled out of existence.
41 if let error = model.actionError {
42 Section {
43 GBNotice(error, .gbWarn)
44 }
45 }
46 if let notice = model.notice {
47 Section {
48 GBNotice(notice, .gbOK)
49 }
50 }
51 emailSection
52 }
53 }
54 .overlay { LoadStateOverlay(state: model.state) }
55 .navigationTitle("Account")
56 .navigationBarTitleDisplayMode(.inline)
57 .task { await model.load() }
58 .refreshable { await model.load() }
59 .sheet(isPresented: $addingSSH) {
60 KeyPasteSheet(
61 heading: "Add SSH Key",
62 prompt: "Paste an authorized_keys line (ssh-ed25519 AAAA…)",
63 scopes: ["full", "git"],
64 working: model.working,
65 errorMessage: model.actionError
66 ) { text, scope in
67 Task {
68 await model.addSSHKey(text, scope: scope ?? "full")
69 if model.actionError == nil { addingSSH = false }
70 }
71 }
72 }
73 .sheet(isPresented: $addingPGP) {
74 KeyPasteSheet(
75 heading: "Add PGP Key",
76 prompt: "Paste an armored public key (-----BEGIN PGP PUBLIC KEY BLOCK-----)",
77 scopes: nil,
78 working: model.working,
79 errorMessage: model.actionError
80 ) { text, _ in
81 Task {
82 await model.addPGPKey(text)
83 if model.actionError == nil { addingPGP = false }
84 }
85 }
86 }
87 .confirmationDialog(
88 "Remove this SSH key? Anything authenticating with it loses access.",
89 isPresented: Binding(
90 get: { removingSSH != nil },
91 set: { if !$0 { removingSSH = nil } }
92 )
93 ) {
94 Button("Remove", role: .destructive) {
95 if let key = removingSSH {
96 Task { await model.removeSSHKey(key) }
97 }
98 removingSSH = nil
99 }
100 Button("Cancel", role: .cancel) {}
101 }
102 .confirmationDialog(
103 "Remove this PGP key? Commits it signed become unverifiable.",
104 isPresented: Binding(
105 get: { removingPGP != nil },
106 set: { if !$0 { removingPGP = nil } }
107 )
108 ) {
109 Button("Remove", role: .destructive) {
110 if let key = removingPGP {
111 Task { await model.removePGPKey(key) }
112 }
113 removingPGP = nil
114 }
115 Button("Cancel", role: .cancel) {}
116 }
117 }
118
119 // MARK: - Sections
120
121 private func sshSection(_ keys: [AccountViewModel.SSHKey]) -> some View {
122 Section {
123 ForEach(keys) { key in
124 VStack(alignment: .leading, spacing: 2) {
125 Text(key.fingerprint)
126 .font(.gbMono(.caption))
127 .lineLimit(1)
128 .truncationMode(.middle)
129 HStack(spacing: 6) {
130 Text(key.algo)
131 GBChip(key.scope, .secondary)
132 }
133 .font(.gbSans(.caption2))
134 .foregroundStyle(.secondary)
135 }
136 .swipeActions {
137 Button("Remove", role: .destructive) {
138 removingSSH = key
139 }
140 }
141 }
142 Button {
143 addingSSH = true
144 } label: {
145 Label("Add SSH Key", systemImage: "plus")
146 .font(.gbSans(.subheadline))
147 }
148 .accessibilityIdentifier("add-ssh-key")
149 } header: {
150 Text("SSH keys")
151 } footer: {
152 Text("A git-scoped key can push and pull but not run account commands.")
153 }
154 }
155
156 private func pgpSection(_ keys: [AccountViewModel.PGPKey]) -> some View {
157 Section {
158 ForEach(keys) { key in
159 VStack(alignment: .leading, spacing: 2) {
160 Text(key.fingerprint)
161 .font(.gbMono(.caption))
162 .lineLimit(1)
163 .truncationMode(.middle)
164 if !key.emailList.isEmpty {
165 Text(key.emailList.joined(separator: ", "))
166 .font(.gbSans(.caption2))
167 .foregroundStyle(.secondary)
168 }
169 }
170 .swipeActions {
171 Button("Remove", role: .destructive) {
172 removingPGP = key
173 }
174 }
175 }
176 Button {
177 addingPGP = true
178 } label: {
179 Label("Add PGP Key", systemImage: "plus")
180 .font(.gbSans(.subheadline))
181 }
182 .accessibilityIdentifier("add-pgp-key")
183 } header: {
184 Text("PGP keys")
185 } footer: {
186 Text("Signed commits verify against these; the badge on the log names the state.")
187 }
188 }
189
190 private var emailSection: some View {
191 Section {
192 HStack {
193 TextField("Add email address", text: $emailAddress)
194 .keyboardType(.emailAddress)
195 .autocorrectionDisabled()
196 .textInputAutocapitalization(.never)
197 .accessibilityIdentifier("email-address")
198 Button("Add") {
199 let address = emailAddress
200 emailAddress = ""
201 Task { await model.addEmail(address) }
202 }
203 .font(.gbSans(.caption))
204 .disabled(!emailAddress.contains("@") || model.working)
205 }
206 HStack {
207 TextField("Verification code", text: $verifyCode)
208 .autocorrectionDisabled()
209 .textInputAutocapitalization(.never)
210 .accessibilityIdentifier("email-code")
211 Button("Verify") {
212 let code = verifyCode
213 verifyCode = ""
214 Task { await model.verifyEmail(code: code) }
215 }
216 .font(.gbSans(.caption))
217 .disabled(verifyCode.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
218 .accessibilityIdentifier("email-verify")
219 }
220 } header: {
221 Text("Email")
222 } footer: {
223 Text("Adding an address mails a code; commits carry your verified identity.")
224 }
225 }
226}
227
228/// Paste-a-key sheet shared by SSH and PGP. Public keys are not secrets.
229private struct KeyPasteSheet: View {
230
231 let heading: String
232 let prompt: String
233 let scopes: [String]?
234 let working: Bool
235 let errorMessage: String?
236 let onSubmit: (String, String?) -> Void
237
238 @Environment(\.dismiss) private var dismiss
239 @State private var text = ""
240 @State private var scope = "full"
241
242 var body: some View {
243 NavigationStack {
244 Form {
245 Section {
246 TextEditor(text: $text)
247 .frame(minHeight: 120)
248 .font(.gbMono(.caption))
249 .autocorrectionDisabled()
250 .textInputAutocapitalization(.never)
251 .accessibilityIdentifier("key-paste-text")
252 } footer: {
253 Text(prompt)
254 }
255 if let scopes {
256 Section {
257 Picker("Scope", selection: $scope) {
258 ForEach(scopes, id: \.self) { Text($0).tag($0) }
259 }
260 .pickerStyle(.segmented)
261 }
262 }
263 if let errorMessage {
264 Section {
265 GBNotice(errorMessage)
266 }
267 }
268 }
269 .navigationTitle(heading)
270 .navigationBarTitleDisplayMode(.inline)
271 .toolbar {
272 ToolbarItem(placement: .cancellationAction) {
273 Button("Cancel") { dismiss() }
274 }
275 ToolbarItem(placement: .confirmationAction) {
276 if working {
277 ProgressView()
278 } else {
279 Button("Add") {
280 onSubmit(text, scopes != nil ? scope : nil)
281 }
282 .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
283 .accessibilityIdentifier("key-paste-submit")
284 }
285 }
286 }
287 .interactiveDismissDisabled(working)
288 }
289 }
290}