Commit 65746904e5
65746904e59ba17913d237db48ef9a2f23fd7de1
parent: 4c7aac9884
Unsigned
cmc <hello@cleberg.net> · 2026-09-08 03:57 UTC
Email addresses: list, remove, make primary (!92)
email list, remove and primary (krz/gitbay#181) on the account screen. Rows carry primary and verified chips; a non-primary address can be removed after a confirmation, and a verified one made primary. Add and verify reload the list. An older server leaves the list absent. The live account flow asserts the primary chip.
Layout: unified · split
gitbay/Account/AccountViewModel.swift
+49
| @@ -29,6 +29,20 @@ final class AccountViewModel { |
| 29 | 29 | } |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | /// One row of `email list` (v1.16.0). |
| 33 | nonisolated struct EmailAddress: Decodable, Sendable, Hashable, Identifiable { |
| 34 | let address: String |
| 35 | let verified: Bool |
| 36 | let verifiedBy: String? |
| 37 | let primary: Bool |
| 38 | var id: String { address } |
| 39 | |
| 40 | enum CodingKeys: String, CodingKey { |
| 41 | case address, verified, primary |
| 42 | case verifiedBy = "verified_by" |
| 43 | } |
| 44 | } |
| 45 | |
| 32 | 46 | nonisolated struct Loaded: Sendable, Hashable { |
| 33 | 47 | let sshKeys: [SSHKey] |
| 34 | 48 | let pgpKeys: [PGPKey] |
| @@ -44,6 +58,9 @@ final class AccountViewModel { |
| 44 | 58 | /// Whether activity mail is on (v1.16.0); nil until read, or on a |
| 45 | 59 | /// server without the setting. Inbox rows are filed either way. |
| 46 | 60 | private(set) var mailOn: Bool? |
| 61 | /// The account's addresses; nil until read, or on a server without |
| 62 | /// `email list`. |
| 63 | private(set) var emails: [EmailAddress]? |
| 47 | 64 | |
| 48 | 65 | private let client: GitbayClient |
| 49 | 66 | |
| @@ -120,6 +137,7 @@ final class AccountViewModel { |
| 120 | 137 | await perform(["email", "add", address.trimmingCharacters(in: .whitespaces)]) |
| 121 | 138 | if actionError == nil { |
| 122 | 139 | notice = "A verification code is on its way to \(address)." |
| 140 | await loadEmails() |
| 123 | 141 | } |
| 124 | 142 | } |
| 125 | 143 | |
| @@ -127,6 +145,37 @@ final class AccountViewModel { |
| 127 | 145 | await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)]) |
| 128 | 146 | if actionError == nil { |
| 129 | 147 | notice = "Email verified." |
| 148 | await loadEmails() |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | func loadEmails() async { |
| 153 | emails = try? await client.readList(["email", "list"], of: EmailAddress.self) |
| 154 | } |
| 155 | |
| 156 | /// `email remove`: the primary and the last verified address are |
| 157 | /// refused by the server, since identity resolves through them. |
| 158 | func removeEmail(_ address: String) async { |
| 159 | await performEmail(["email", "remove", address]) |
| 160 | } |
| 161 | |
| 162 | /// `email primary`: moves the primary to a verified address. |
| 163 | func setPrimaryEmail(_ address: String) async { |
| 164 | await performEmail(["email", "primary", address]) |
| 165 | } |
| 166 | |
| 167 | private func performEmail(_ argv: [String]) async { |
| 168 | working = true |
| 169 | actionError = nil |
| 170 | notice = nil |
| 171 | defer { working = false } |
| 172 | do { |
| 173 | try await client.run(argv) |
| 174 | await loadEmails() |
| 175 | } catch let error as GitbayError { |
| 176 | actionError = error.userFacingMessage |
| 177 | } catch { |
| 178 | actionError = GitbayError.transport(error).userFacingMessage |
| 130 | 179 | } |
| 131 | 180 | } |
| 132 | 181 | |
gitbay/Views/Account/AccountView.swift
+46 −1
| @@ -9,6 +9,7 @@ struct AccountView: View { |
| 9 | 9 | @State private var addingPGP = false |
| 10 | 10 | @State private var emailAddress = "" |
| 11 | 11 | @State private var verifyCode = "" |
| 12 | @State private var removingEmail: AccountViewModel.EmailAddress? |
| 12 | 13 | @State private var removingSSH: AccountViewModel.SSHKey? |
| 13 | 14 | @State private var removingPGP: AccountViewModel.PGPKey? |
| 14 | 15 | |
| @@ -57,9 +58,27 @@ struct AccountView: View { |
| 57 | 58 | .navigationBarTitleDisplayMode(.inline) |
| 58 | 59 | .task { await model.load() } |
| 59 | 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 | } |
| 60 | 78 | .refreshable { |
| 61 | 79 | await model.load() |
| 62 | 80 | await model.loadMailPreference() |
| 81 | await model.loadEmails() |
| 63 | 82 | } |
| 64 | 83 | .sheet(isPresented: $addingSSH) { |
| 65 | 84 | KeyPasteSheet( |
| @@ -212,6 +231,32 @@ struct AccountView: View { |
| 212 | 231 | |
| 213 | 232 | private var emailSection: some View { |
| 214 | 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 | } |
| 215 | 260 | HStack { |
| 216 | 261 | TextField("Add email address", text: $emailAddress) |
| 217 | 262 | .keyboardType(.emailAddress) |
| @@ -243,7 +288,7 @@ struct AccountView: View { |
| 243 | 288 | } header: { |
| 244 | 289 | Text("Email") |
| 245 | 290 | } footer: { |
| 246 | | Text("Adding an address mails a code; commits carry your verified identity.") |
| 291 | Text("Adding an address mails a code; commits carry your verified identity. The primary and the last verified address cannot be removed.") |
| 247 | 292 | } |
| 248 | 293 | } |
| 249 | 294 | } |
gitbayTests/AccountTests.swift
+47
| @@ -76,6 +76,53 @@ struct AccountViewModelTests { |
| 76 | 76 | #expect(model.actionError == nil) |
| 77 | 77 | } |
| 78 | 78 | |
| 79 | /// v1.16.0's `email list`, `email remove` and `email primary`. The |
| 80 | /// list is its own read, and an older server leaves it nil. |
| 81 | @Test func emailsLoadWithVerifiedAndPrimaryFlags() async throws { |
| 82 | let (model, stub) = try await loadedModel() |
| 83 | stub.enqueue(.init(status: 200, json: """ |
| 84 | {"protocol_version":1,"data":[\ |
| 85 | {"address":"hello@cleberg.net","verified":true,"verified_by":"code","primary":true},\ |
| 86 | {"address":"c@example.test","verified":false,"primary":false}],"exit_code":0} |
| 87 | """, match: "argv=email&argv=list")) |
| 88 | |
| 89 | await model.loadEmails() |
| 90 | |
| 91 | let emails = try #require(model.emails) |
| 92 | #expect(emails.map(\.address) == ["hello@cleberg.net", "c@example.test"]) |
| 93 | #expect(emails[0].verified && emails[0].primary) |
| 94 | #expect(!emails[1].verified && !emails[1].primary) |
| 95 | } |
| 96 | |
| 97 | @Test func anOlderServerLeavesTheEmailListUnknown() async throws { |
| 98 | let (model, stub) = try await loadedModel() |
| 99 | stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=email&argv=list")) |
| 100 | |
| 101 | await model.loadEmails() |
| 102 | |
| 103 | #expect(model.emails == nil) |
| 104 | #expect(model.actionError == nil) |
| 105 | } |
| 106 | |
| 107 | @Test func removeAndPrimarySendTheirCommandsThenReloadEmails() async throws { |
| 108 | let (model, stub) = try await loadedModel() |
| 109 | let listJSON = #"{"protocol_version":1,"data":[{"address":"hello@cleberg.net","verified":true,"primary":true}],"exit_code":0}"# |
| 110 | for _ in 0..<2 { |
| 111 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) |
| 112 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=email&argv=list")) |
| 113 | } |
| 114 | |
| 115 | await model.removeEmail("c@example.test") |
| 116 | await model.setPrimaryEmail("hello@cleberg.net") |
| 117 | |
| 118 | let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 } |
| 119 | #expect(writes == [ |
| 120 | ["email", "remove", "c@example.test"], |
| 121 | ["email", "primary", "hello@cleberg.net"], |
| 122 | ]) |
| 123 | #expect(stub.seen.filter { $0.url.query()?.contains("argv=email&argv=list") == true }.count == 2) |
| 124 | } |
| 125 | |
| 79 | 126 | @Test func loadsBothKeyListsAndDecodesNestedEmails() async throws { |
| 80 | 127 | let (model, _) = try await loadedModel() |
| 81 | 128 | |
gitbayUITests/LiveSmokeUITests.swift
+2
| @@ -866,6 +866,8 @@ extension LiveSmokeUITests { |
| 866 | 866 | // Email sits below the keys on a List, which does not build rows |
| 867 | 867 | // it has not shown. |
| 868 | 868 | XCTAssertTrue(scrollTo(code), "email section not reachable") |
| 869 | // The addresses list above the fields, the primary one marked. |
| 870 | XCTAssertTrue(app.staticTexts["primary"].firstMatch.exists, "primary address not marked") |
| 869 | 871 | focusAndType(code, "000000") |
| 870 | 872 | let verify = app.descendants(matching: .any) |
| 871 | 873 | .matching(identifier: "email-verify").firstMatch |