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 nonisolated struct Loaded: Sendable, Hashable { 46 nonisolated struct Loaded: Sendable, Hashable {
33 let sshKeys: [SSHKey] 47 let sshKeys: [SSHKey]
34 let pgpKeys: [PGPKey] 48 let pgpKeys: [PGPKey]
@@ -44,6 +58,9 @@ final class AccountViewModel {
44 /// Whether activity mail is on (v1.16.0); nil until read, or on a 58 /// Whether activity mail is on (v1.16.0); nil until read, or on a
45 /// server without the setting. Inbox rows are filed either way. 59 /// server without the setting. Inbox rows are filed either way.
46 private(set) var mailOn: Bool? 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 private let client: GitbayClient 65 private let client: GitbayClient
49 66
@@ -120,6 +137,7 @@ final class AccountViewModel {
120 await perform(["email", "add", address.trimmingCharacters(in: .whitespaces)]) 137 await perform(["email", "add", address.trimmingCharacters(in: .whitespaces)])
121 if actionError == nil { 138 if actionError == nil {
122 notice = "A verification code is on its way to \(address)." 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 await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)]) 145 await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)])
128 if actionError == nil { 146 if actionError == nil {
129 notice = "Email verified." 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 @State private var addingPGP = false 9 @State private var addingPGP = false
10 @State private var emailAddress = "" 10 @State private var emailAddress = ""
11 @State private var verifyCode = "" 11 @State private var verifyCode = ""
12 @State private var removingEmail: AccountViewModel.EmailAddress?
12 @State private var removingSSH: AccountViewModel.SSHKey? 13 @State private var removingSSH: AccountViewModel.SSHKey?
13 @State private var removingPGP: AccountViewModel.PGPKey? 14 @State private var removingPGP: AccountViewModel.PGPKey?
14 15
@@ -57,9 +58,27 @@ struct AccountView: View {
57 .navigationBarTitleDisplayMode(.inline) 58 .navigationBarTitleDisplayMode(.inline)
58 .task { await model.load() } 59 .task { await model.load() }
59 .task { await model.loadMailPreference() } 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 .refreshable { 78 .refreshable {
61 await model.load() 79 await model.load()
62 await model.loadMailPreference() 80 await model.loadMailPreference()
81 await model.loadEmails()
63 } 82 }
64 .sheet(isPresented: $addingSSH) { 83 .sheet(isPresented: $addingSSH) {
65 KeyPasteSheet( 84 KeyPasteSheet(
@@ -212,6 +231,32 @@ struct AccountView: View {
212 231
213 private var emailSection: some View { 232 private var emailSection: some View {
214 Section { 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 HStack { 260 HStack {
216 TextField("Add email address", text: $emailAddress) 261 TextField("Add email address", text: $emailAddress)
217 .keyboardType(.emailAddress) 262 .keyboardType(.emailAddress)
@@ -243,7 +288,7 @@ struct AccountView: View {
243 } header: { 288 } header: {
244 Text("Email") 289 Text("Email")
245 } footer: { 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 #expect(model.actionError == nil) 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 @Test func loadsBothKeyListsAndDecodesNestedEmails() async throws { 126 @Test func loadsBothKeyListsAndDecodesNestedEmails() async throws {
80 let (model, _) = try await loadedModel() 127 let (model, _) = try await loadedModel()
81 128
gitbayUITests/LiveSmokeUITests.swift +2
@@ -866,6 +866,8 @@ extension LiveSmokeUITests {
866 // Email sits below the keys on a List, which does not build rows 866 // Email sits below the keys on a List, which does not build rows
867 // it has not shown. 867 // it has not shown.
868 XCTAssertTrue(scrollTo(code), "email section not reachable") 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 focusAndType(code, "000000") 871 focusAndType(code, "000000")
870 let verify = app.descendants(matching: .any) 872 let verify = app.descendants(matching: .any)
871 .matching(identifier: "email-verify").firstMatch 873 .matching(identifier: "email-verify").firstMatch