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