Commit 65746904e5
Unsigned
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 |