a native ios client for gitbay

client ios swift

https://gitbay.org

account: SSH keys, PGP keys, email verification !9

merged cmc wants to merge krz/gitbay-ios:account-keys into main

7 files changed, +588 −1

gitbay/Account/AccountViewModel.swift added +121
@@ -0,0 +1,121 @@
1import Foundation
2import Observation
3
4/// SSH keys, PGP keys, and email verification the account rows the web
5/// has. Tokens stay SSH-only by design and get no UI here.
6@Observable
7@MainActor
8final class AccountViewModel {
9
10 nonisolated struct SSHKey: Decodable, Sendable, Hashable, Identifiable {
11 let fingerprint: String
12 let algo: String
13 let scope: String
14 var id: String { fingerprint }
15 }
16
17 nonisolated struct PGPKey: Decodable, Sendable, Hashable, Identifiable {
18 let fingerprint: String
19 /// The server stores the UID list JSON-encoded inside the field.
20 let emails: String?
21 var id: String { fingerprint }
22
23 var emailList: [String] {
24 guard let emails,
25 let data = try? JSONDecoder().decode([String].self, from: Data(emails.utf8)) else {
26 return []
27 }
28 return data
29 }
30 }
31
32 nonisolated struct Loaded: Sendable, Hashable {
33 let sshKeys: [SSHKey]
34 let pgpKeys: [PGPKey]
35 }
36
37 private(set) var state: LoadState<Loaded> = .loading
38 private(set) var actionError: String?
39 /// Set after `email add` succeeds, so the screen can say a code is
40 /// on its way.
41 private(set) var notice: String?
42 private(set) var working = false
43
44 private let client: GitbayClient
45
46 init(client: GitbayClient) {
47 self.client = client
48 }
49
50 func load() async {
51 do {
52 async let ssh = client.readList(["keys", "list"], of: SSHKey.self)
53 async let pgp = client.readList(["pgp", "list"], of: PGPKey.self)
54 state = .loaded(Loaded(sshKeys: try await ssh, pgpKeys: try await pgp))
55 } catch {
56 state = .from(error)
57 }
58 }
59
60 // MARK: - SSH keys
61
62 /// `keys add [--scope full|git]` the authorized_keys line travels
63 /// as raw stdin; a public key is not a secret.
64 func addSSHKey(_ publicKey: String, scope: String) async {
65 await perform(["keys", "add", "--scope", scope],
66 stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines))
67 }
68
69 func removeSSHKey(_ key: SSHKey) async {
70 await perform(["keys", "remove", key.fingerprint])
71 }
72
73 // MARK: - PGP keys
74
75 func addPGPKey(_ armored: String) async {
76 await perform(["pgp", "add"],
77 stdin: armored.trimmingCharacters(in: .whitespacesAndNewlines))
78 }
79
80 func removePGPKey(_ key: PGPKey) async {
81 await perform(["pgp", "remove", key.fingerprint])
82 }
83
84 // MARK: - Email
85
86 func addEmail(_ address: String) async {
87 await perform(["email", "add", address.trimmingCharacters(in: .whitespaces)])
88 if actionError == nil {
89 notice = "A verification code is on its way to \(address)."
90 }
91 }
92
93 func verifyEmail(code: String) async {
94 await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)])
95 if actionError == nil {
96 notice = "Email verified."
97 }
98 }
99
100 private func perform(_ argv: [String], stdin: String? = nil) async {
101 working = true
102 actionError = nil
103 notice = nil
104 defer { working = false }
105 do {
106 try await client.run(argv, stdin: stdin)
107 await load()
108 } catch let error as GitbayError {
109 // Exit 2 is normally an app bug and stays generic, but on
110 // this screen it validates pasted content ("not a valid
111 // public key", duplicates) written for the person.
112 if case .usage(let message) = error, !message.isEmpty {
113 actionError = message
114 } else {
115 actionError = error.userFacingMessage
116 }
117 } catch {
118 actionError = GitbayError.transport(error).userFacingMessage
119 }
120 }
121}
gitbay/ContentView.swift +2
@@ -77,6 +77,8 @@ private struct RouteDestinations: ViewModifier {
7777 GrepView(client: client, repo: repo)
7878 case .profile(let name):
7979 ProfileView(client: client, name: name)
80 case .account:
81 AccountView(client: client)
8082 case .addAccount:
8183 SignInView()
8284 }
gitbay/Views/Account/AccountView.swift added +285
@@ -0,0 +1,285 @@
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 sshSection(loaded.sshKeys)
23 pgpSection(loaded.pgpKeys)
24 // Feedback sits beside the email actions: with the
25 // keyboard up, a banner at the top of the list is
26 // scrolled out of existence.
27 if let error = model.actionError {
28 Section {
29 Label(error, systemImage: "hand.raised")
30 .foregroundStyle(.orange)
31 .font(.subheadline)
32 }
33 }
34 if let notice = model.notice {
35 Section {
36 Label(notice, systemImage: "envelope")
37 .foregroundStyle(.secondary)
38 .font(.subheadline)
39 }
40 }
41 emailSection
42 }
43 }
44 .overlay { LoadStateOverlay(state: model.state) }
45 .navigationTitle("Account")
46 .navigationBarTitleDisplayMode(.inline)
47 .task { await model.load() }
48 .refreshable { await model.load() }
49 .sheet(isPresented: $addingSSH) {
50 KeyPasteSheet(
51 heading: "Add SSH Key",
52 prompt: "Paste an authorized_keys line (ssh-ed25519 AAAA…)",
53 scopes: ["full", "git"],
54 working: model.working,
55 errorMessage: model.actionError
56 ) { text, scope in
57 Task {
58 await model.addSSHKey(text, scope: scope ?? "full")
59 if model.actionError == nil { addingSSH = false }
60 }
61 }
62 }
63 .sheet(isPresented: $addingPGP) {
64 KeyPasteSheet(
65 heading: "Add PGP Key",
66 prompt: "Paste an armored public key (-----BEGIN PGP PUBLIC KEY BLOCK-----)",
67 scopes: nil,
68 working: model.working,
69 errorMessage: model.actionError
70 ) { text, _ in
71 Task {
72 await model.addPGPKey(text)
73 if model.actionError == nil { addingPGP = false }
74 }
75 }
76 }
77 .confirmationDialog(
78 "Remove this SSH key? Anything authenticating with it loses access.",
79 isPresented: Binding(
80 get: { removingSSH != nil },
81 set: { if !$0 { removingSSH = nil } }
82 )
83 ) {
84 Button("Remove", role: .destructive) {
85 if let key = removingSSH {
86 Task { await model.removeSSHKey(key) }
87 }
88 removingSSH = nil
89 }
90 Button("Cancel", role: .cancel) {}
91 }
92 .confirmationDialog(
93 "Remove this PGP key? Commits it signed become unverifiable.",
94 isPresented: Binding(
95 get: { removingPGP != nil },
96 set: { if !$0 { removingPGP = nil } }
97 )
98 ) {
99 Button("Remove", role: .destructive) {
100 if let key = removingPGP {
101 Task { await model.removePGPKey(key) }
102 }
103 removingPGP = nil
104 }
105 Button("Cancel", role: .cancel) {}
106 }
107 }
108
109 // MARK: - Sections
110
111 private func sshSection(_ keys: [AccountViewModel.SSHKey]) -> some View {
112 Section {
113 ForEach(keys) { key in
114 VStack(alignment: .leading, spacing: 2) {
115 Text(key.fingerprint)
116 .font(.caption.monospaced())
117 .lineLimit(1)
118 .truncationMode(.middle)
119 HStack(spacing: 6) {
120 Text(key.algo)
121 Text(key.scope)
122 .padding(.horizontal, 5)
123 .padding(.vertical, 1)
124 .background(.quaternary, in: Capsule())
125 }
126 .font(.caption2)
127 .foregroundStyle(.secondary)
128 }
129 .swipeActions {
130 Button("Remove", role: .destructive) {
131 removingSSH = key
132 }
133 }
134 }
135 Button {
136 addingSSH = true
137 } label: {
138 Label("Add SSH Key", systemImage: "plus")
139 .font(.subheadline)
140 }
141 .accessibilityIdentifier("add-ssh-key")
142 } header: {
143 Text("SSH keys")
144 } footer: {
145 Text("A git-scoped key can push and pull but not run account commands.")
146 }
147 }
148
149 private func pgpSection(_ keys: [AccountViewModel.PGPKey]) -> some View {
150 Section {
151 ForEach(keys) { key in
152 VStack(alignment: .leading, spacing: 2) {
153 Text(key.fingerprint)
154 .font(.caption.monospaced())
155 .lineLimit(1)
156 .truncationMode(.middle)
157 if !key.emailList.isEmpty {
158 Text(key.emailList.joined(separator: ", "))
159 .font(.caption2)
160 .foregroundStyle(.secondary)
161 }
162 }
163 .swipeActions {
164 Button("Remove", role: .destructive) {
165 removingPGP = key
166 }
167 }
168 }
169 Button {
170 addingPGP = true
171 } label: {
172 Label("Add PGP Key", systemImage: "plus")
173 .font(.subheadline)
174 }
175 .accessibilityIdentifier("add-pgp-key")
176 } header: {
177 Text("PGP keys")
178 } footer: {
179 Text("Signed commits verify against these; the badge on the log names the state.")
180 }
181 }
182
183 private var emailSection: some View {
184 Section {
185 HStack {
186 TextField("Add email address", text: $emailAddress)
187 .keyboardType(.emailAddress)
188 .autocorrectionDisabled()
189 .textInputAutocapitalization(.never)
190 .accessibilityIdentifier("email-address")
191 Button("Add") {
192 let address = emailAddress
193 emailAddress = ""
194 Task { await model.addEmail(address) }
195 }
196 .font(.caption)
197 .disabled(!emailAddress.contains("@") || model.working)
198 }
199 HStack {
200 TextField("Verification code", text: $verifyCode)
201 .autocorrectionDisabled()
202 .textInputAutocapitalization(.never)
203 .accessibilityIdentifier("email-code")
204 Button("Verify") {
205 let code = verifyCode
206 verifyCode = ""
207 Task { await model.verifyEmail(code: code) }
208 }
209 .font(.caption)
210 .disabled(verifyCode.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
211 .accessibilityIdentifier("email-verify")
212 }
213 } header: {
214 Text("Email")
215 } footer: {
216 Text("Adding an address mails a code; commits carry your verified identity.")
217 }
218 }
219}
220
221/// Paste-a-key sheet shared by SSH and PGP. Public keys are not secrets.
222private struct KeyPasteSheet: View {
223
224 let heading: String
225 let prompt: String
226 let scopes: [String]?
227 let working: Bool
228 let errorMessage: String?
229 let onSubmit: (String, String?) -> Void
230
231 @Environment(\.dismiss) private var dismiss
232 @State private var text = ""
233 @State private var scope = "full"
234
235 var body: some View {
236 NavigationStack {
237 Form {
238 Section {
239 TextEditor(text: $text)
240 .frame(minHeight: 120)
241 .font(.caption.monospaced())
242 .autocorrectionDisabled()
243 .textInputAutocapitalization(.never)
244 .accessibilityIdentifier("key-paste-text")
245 } footer: {
246 Text(prompt)
247 }
248 if let scopes {
249 Section {
250 Picker("Scope", selection: $scope) {
251 ForEach(scopes, id: \.self) { Text($0).tag($0) }
252 }
253 .pickerStyle(.segmented)
254 }
255 }
256 if let errorMessage {
257 Section {
258 Label(errorMessage, systemImage: "exclamationmark.triangle")
259 .foregroundStyle(.red)
260 .font(.subheadline)
261 }
262 }
263 }
264 .navigationTitle(heading)
265 .navigationBarTitleDisplayMode(.inline)
266 .toolbar {
267 ToolbarItem(placement: .cancellationAction) {
268 Button("Cancel") { dismiss() }
269 }
270 ToolbarItem(placement: .confirmationAction) {
271 if working {
272 ProgressView()
273 } else {
274 Button("Add") {
275 onSubmit(text, scopes != nil ? scope : nil)
276 }
277 .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
278 .accessibilityIdentifier("key-paste-submit")
279 }
280 }
281 }
282 .interactiveDismissDisabled(working)
283 }
284 }
285}
gitbay/Views/Dashboard/DashboardView.swift +9 −1
@@ -30,7 +30,15 @@ struct DashboardView: View {
3030 }
3131 .overlay { LoadStateOverlay(state: model.state) }
3232 .navigationTitle("Dashboard")
33 .toolbar { AccountMenu() }
33 .toolbar {
34 ToolbarItem(placement: .topBarTrailing) {
35 NavigationLink(value: RepoRoute.account) {
36 Image(systemName: "key")
37 }
38 .accessibilityIdentifier("account-screen-link")
39 }
40 AccountMenu()
41 }
3442 .task { await model.load() }
3543 .refreshable { await model.load() }
3644 }
gitbay/Views/Repos/RepoRoute.swift +1
@@ -10,5 +10,6 @@ nonisolated enum RepoRoute: Hashable {
1010 case settings(repo: String)
1111 case grep(repo: String)
1212 case profile(String)
13 case account
1314 case addAccount
1415 }
gitbayTests/AccountTests.swift added +115
@@ -0,0 +1,115 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) {
16 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
17 return (try #require(body["argv"] as? [String]), body["stdin"] as? String)
18}
19
20private let keysJSON = """
21 {"protocol_version":1,"data":[\
22 {"fingerprint":"SHA256:15jrWGl3s3BB1CeG0z9TfGnyf35l8lcBWoYfA0+IJbY","algo":"ssh-ed25519","scope":"full"}\
23 ],"exit_code":0}
24 """
25private let pgpJSON = """
26 {"protocol_version":1,"data":[\
27 {"fingerprint":"3917973fb159bbb86194538569451a517ac0cb37",\
28 "emails":"[\\"hello@cleberg.net\\"]"}],"exit_code":0}
29 """
30private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
31
32@MainActor
33struct AccountViewModelTests {
34
35 private func loadedModel() async throws -> (AccountViewModel, StubProtocol.Box) {
36 let (client, stub) = try makeClient()
37 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
38 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
39 let model = AccountViewModel(client: client)
40 await model.load()
41 return (model, stub)
42 }
43
44 @Test func loadsBothKeyListsAndDecodesNestedEmails() async throws {
45 let (model, _) = try await loadedModel()
46
47 let loaded = try #require(model.state.value)
48 #expect(loaded.sshKeys.first?.algo == "ssh-ed25519")
49 // The UID list is JSON-encoded inside the JSON field.
50 #expect(loaded.pgpKeys.first?.emailList == ["hello@cleberg.net"])
51 }
52
53 @Test func sshKeyTravelsAsRawStdinWithScope() async throws {
54 let (model, stub) = try await loadedModel()
55 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
56 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
57 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
58
59 await model.addSSHKey("ssh-ed25519 AAAAC3Nza phone\n", scope: "git")
60
61 let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
62 // No --file - here: keys add reads bare stdin.
63 #expect(argv == ["keys", "add", "--scope", "git"])
64 #expect(stdin == "ssh-ed25519 AAAAC3Nza phone")
65 }
66
67 @Test func removalsTargetTheFingerprint() async throws {
68 let (model, stub) = try await loadedModel()
69 for _ in 0..<2 {
70 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
71 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
72 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
73 }
74 let loaded = try #require(model.state.value)
75
76 await model.removeSSHKey(loaded.sshKeys[0])
77 await model.removePGPKey(loaded.pgpKeys[0])
78
79 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
80 #expect(writes[0] == ["keys", "remove", "SHA256:15jrWGl3s3BB1CeG0z9TfGnyf35l8lcBWoYfA0+IJbY"])
81 #expect(writes[1] == ["pgp", "remove", "3917973fb159bbb86194538569451a517ac0cb37"])
82 }
83
84 @Test func emailAddAndVerifySendTheirCommands() async throws {
85 let (model, stub) = try await loadedModel()
86 for _ in 0..<2 {
87 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
88 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
89 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
90 }
91
92 await model.addEmail(" claude@cleberg.net ")
93 #expect(model.notice?.contains("on its way") == true)
94 await model.verifyEmail(code: "123456")
95 #expect(model.notice == "Email verified.")
96
97 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
98 #expect(writes[0] == ["email", "add", "claude@cleberg.net"])
99 #expect(writes[1] == ["email", "verify", "123456"])
100 }
101
102 @Test func contentValidationUsageErrorsSurfaceVerbatimHere() async throws {
103 let (model, stub) = try await loadedModel()
104 stub.enqueue(.init(status: 400, json:
105 #"{"protocol_version":1,"error":"not a valid public key in authorized_keys format: illegal base64","exit_code":2}"#,
106 match: "cmd"))
107
108 await model.addSSHKey("garbage", scope: "full")
109
110 // Exit 2 stays generic app-wide; on this screen it validates
111 // pasted content and the message is for the person.
112 #expect(model.actionError ==
113 "not a valid public key in authorized_keys format: illegal base64")
114 }
115}
gitbayUITests/LiveSmokeUITests.swift +55
@@ -360,3 +360,58 @@ extension LiveSmokeUITests {
360360 app.buttons["Cancel"].firstMatch.tap()
361361 }
362362 }
363
364extension LiveSmokeUITests {
365
366 /// Account keys and email. Read-only plus refusal paths no key is
367 /// added or removed, no mail is sent.
368 func testAccountFlows() throws {
369 // Dashboard toolbar -> account screen.
370 let accountLink = app.descendants(matching: .any)
371 .matching(identifier: "account-screen-link").firstMatch
372 XCTAssertTrue(accountLink.waitForExistence(timeout: 10))
373 accountLink.tap()
374
375 // Real keys render: SSH fingerprints and the PGP key's UID email.
376 XCTAssertTrue(app.staticTexts
377 .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch
378 .waitForExistence(timeout: 15), "SSH keys missing")
379 XCTAssertTrue(app.staticTexts["hello@cleberg.net"].firstMatch
380 .waitForExistence(timeout: 10), "PGP key UID missing")
381
382 // Pasting garbage as an SSH key surfaces the server's validation.
383 app.descendants(matching: .any).matching(identifier: "add-ssh-key")
384 .firstMatch.tap()
385 let paste = app.descendants(matching: .any)
386 .matching(identifier: "key-paste-text").firstMatch
387 XCTAssertTrue(paste.waitForExistence(timeout: 5))
388 paste.tap()
389 paste.typeText("not a key")
390 app.descendants(matching: .any).matching(identifier: "key-paste-submit")
391 .firstMatch.tap()
392 XCTAssertTrue(app.staticTexts
393 .containing(NSPredicate(format: "label CONTAINS 'not a valid public key'")).firstMatch
394 .waitForExistence(timeout: 15), "invalid-key refusal not surfaced")
395 app.buttons["Cancel"].firstMatch.tap()
396
397 // A bogus verification code is refused, not swallowed.
398 let code = app.descendants(matching: .any)
399 .matching(identifier: "email-code").firstMatch
400 XCTAssertTrue(code.waitForExistence(timeout: 5))
401 code.tap()
402 XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
403 code.typeText("000000")
404 if (code.value as? String)?.contains("000000") != true {
405 // The first keystrokes can race focus; type once more.
406 code.typeText("000000")
407 }
408 let verify = app.descendants(matching: .any)
409 .matching(identifier: "email-verify").firstMatch
410 XCTAssertTrue(verify.waitForExistence(timeout: 5))
411 XCTAssertTrue(verify.isEnabled, "verify stayed disabled — code text never landed")
412 verify.tap()
413 XCTAssertTrue(app.staticTexts
414 .containing(NSPredicate(format: "label CONTAINS 'invalid, expired'")).firstMatch
415 .waitForExistence(timeout: 15), "bad-code refusal not surfaced")
416 }
417}