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 {
2929 }
3030 }
3131
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
3246 nonisolated struct Loaded: Sendable, Hashable {
3347 let sshKeys: [SSHKey]
3448 let pgpKeys: [PGPKey]
@@ -44,6 +58,9 @@ final class AccountViewModel {
4458 /// Whether activity mail is on (v1.16.0); nil until read, or on a
4559 /// server without the setting. Inbox rows are filed either way.
4660 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]?
4764
4865 private let client: GitbayClient
4966
@@ -120,6 +137,7 @@ final class AccountViewModel {
120137 await perform(["email", "add", address.trimmingCharacters(in: .whitespaces)])
121138 if actionError == nil {
122139 notice = "A verification code is on its way to \(address)."
140 await loadEmails()
123141 }
124142 }
125143
@@ -127,6 +145,37 @@ final class AccountViewModel {
127145 await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)])
128146 if actionError == nil {
129147 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
130179 }
131180 }
132181
gitbay/Views/Account/AccountView.swift +46 −1
@@ -9,6 +9,7 @@ struct AccountView: View {
99 @State private var addingPGP = false
1010 @State private var emailAddress = ""
1111 @State private var verifyCode = ""
12 @State private var removingEmail: AccountViewModel.EmailAddress?
1213 @State private var removingSSH: AccountViewModel.SSHKey?
1314 @State private var removingPGP: AccountViewModel.PGPKey?
1415
@@ -57,9 +58,27 @@ struct AccountView: View {
5758 .navigationBarTitleDisplayMode(.inline)
5859 .task { await model.load() }
5960 .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 }
6078 .refreshable {
6179 await model.load()
6280 await model.loadMailPreference()
81 await model.loadEmails()
6382 }
6483 .sheet(isPresented: $addingSSH) {
6584 KeyPasteSheet(
@@ -212,6 +231,32 @@ struct AccountView: View {
212231
213232 private var emailSection: some View {
214233 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 }
215260 HStack {
216261 TextField("Add email address", text: $emailAddress)
217262 .keyboardType(.emailAddress)
@@ -243,7 +288,7 @@ struct AccountView: View {
243288 } header: {
244289 Text("Email")
245290 } 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.")
247292 }
248293 }
249294}
gitbayTests/AccountTests.swift +47
@@ -76,6 +76,53 @@ struct AccountViewModelTests {
7676 #expect(model.actionError == nil)
7777 }
7878
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
79126 @Test func loadsBothKeyListsAndDecodesNestedEmails() async throws {
80127 let (model, _) = try await loadedModel()
81128
gitbayUITests/LiveSmokeUITests.swift +2
@@ -866,6 +866,8 @@ extension LiveSmokeUITests {
866866 // Email sits below the keys on a List, which does not build rows
867867 // it has not shown.
868868 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")
869871 focusAndType(code, "000000")
870872 let verify = app.descendants(matching: .any)
871873 .matching(identifier: "email-verify").firstMatch