Commit e55d2e00ca
Verified · cmc
Layout: unified · split
gitbay/Admin/AdminAccount.swift +31 −5
| @@ -114,7 +114,9 @@ nonisolated struct OwnerLimits: Decodable, Sendable, Hashable { | |||
| 114 | return true | 114 | return true |
| 115 | } | 115 | } |
| 116 | guard let n = Int64(text), n >= 0 else { return false } | 116 | guard let n = Int64(text), n >= 0 else { return false } |
| 117 | flags += [flag, String(n * scale)] | 117 | let (scaled, overflow) = n.multipliedReportingOverflow(by: scale) |
| 118 | guard !overflow else { return false } | ||
| 119 | flags += [flag, String(scaled)] | ||
| 118 | return true | 120 | return true |
| 119 | } | 121 | } |
| 120 | guard add("--repos", repos), add("--bytes", megabytes, scale: 1_000_000) else { return nil } | 122 | guard add("--repos", repos), add("--bytes", megabytes, scale: 1_000_000) else { return nil } |
| @@ -132,6 +134,8 @@ final class AdminAccountViewModel { | |||
| 132 | nonisolated struct Loaded: Sendable, Hashable { | 134 | nonisolated struct Loaded: Sendable, Hashable { |
| 133 | let detail: AdminAccountDetail | 135 | let detail: AdminAccountDetail |
| 134 | let limits: OwnerLimits? | 136 | let limits: OwnerLimits? |
| 137 | /// Why the caps could not be read, when they could not. | ||
| 138 | let limitsError: String? | ||
| 135 | } | 139 | } |
| 136 | 140 | ||
| 137 | private(set) var state: LoadState<Loaded> = .loading | 141 | private(set) var state: LoadState<Loaded> = .loading |
| @@ -150,14 +154,26 @@ final class AdminAccountViewModel { | |||
| 150 | do { | 154 | do { |
| 151 | async let detail = client.read(["admin", "user", "show", username], as: AdminAccountDetail.self) | 155 | async let detail = client.read(["admin", "user", "show", username], as: AdminAccountDetail.self) |
| 152 | // Not a read-only command (it also sets caps), so it goes as a | 156 | // Not a read-only command (it also sets caps), so it goes as a |
| 153 | // command even to read; with no flags it changes nothing. | 157 | // command even to read; with no flags it changes nothing. A |
| 154 | async let limits = try? client.run(["admin", "user", "limits", username], as: OwnerLimits.self) | 158 | // failed read says why under the account, not as its absence. |
| 155 | state = .loaded(Loaded(detail: try await detail, limits: await limits)) | 159 | async let limits = readLimits() |
| 160 | let (caps, why) = await limits | ||
| 161 | state = .loaded(Loaded(detail: try await detail, limits: caps, limitsError: why)) | ||
| 156 | } catch { | 162 | } catch { |
| 157 | state = .from(error) | 163 | state = .from(error) |
| 158 | } | 164 | } |
| 159 | } | 165 | } |
| 160 | 166 | ||
| 167 | private func readLimits() async -> (OwnerLimits?, String?) { | ||
| 168 | do { | ||
| 169 | return (try await client.run(["admin", "user", "limits", username], as: OwnerLimits.self), nil) | ||
| 170 | } catch let error as GitbayError { | ||
| 171 | return (nil, error.userFacingMessage) | ||
| 172 | } catch { | ||
| 173 | return (nil, GitbayError.transport(error).userFacingMessage) | ||
| 174 | } | ||
| 175 | } | ||
| 176 | |||
| 161 | /// Returns whether the caps changed; a field that is not a number or | 177 | /// Returns whether the caps changed; a field that is not a number or |
| 162 | /// `default` is refused here, before the server is asked. | 178 | /// `default` is refused here, before the server is asked. |
| 163 | func setLimits(repos: String, megabytes: String, orgs: String) async -> Bool { | 179 | func setLimits(repos: String, megabytes: String, orgs: String) async -> Bool { |
| @@ -172,15 +188,25 @@ final class AdminAccountViewModel { | |||
| 172 | /// `admin user delete <username> --yes`, for an account that anchors | 188 | /// `admin user delete <username> --yes`, for an account that anchors |
| 173 | /// nothing; the server's refusal names what it still holds. | 189 | /// nothing; the server's refusal names what it still holds. |
| 174 | func delete() async -> Bool { | 190 | func delete() async -> Bool { |
| 175 | await perform(["admin", "user", "delete", username, "--yes"], reload: false) | 191 | if await perform(["admin", "user", "delete", username, "--yes"], reload: false) { return true } |
| 192 | // A failure is retried once; a delete that went through and then | ||
| 193 | // failed answers the retry with not found, and the account is gone. | ||
| 194 | return notFoundOnRetry | ||
| 176 | } | 195 | } |
| 177 | 196 | ||
| 197 | private var notFoundOnRetry = false | ||
| 198 | |||
| 178 | private func perform(_ argv: [String], reload: Bool = true) async -> Bool { | 199 | private func perform(_ argv: [String], reload: Bool = true) async -> Bool { |
| 179 | working = true | 200 | working = true |
| 180 | actionError = nil | 201 | actionError = nil |
| 181 | defer { working = false } | 202 | defer { working = false } |
| 203 | notFoundOnRetry = false | ||
| 182 | do { | 204 | do { |
| 183 | try await client.run(argv) | 205 | try await client.run(argv) |
| 206 | } catch GitbayError.notFound(let message) where argv.contains("delete") { | ||
| 207 | notFoundOnRetry = true | ||
| 208 | actionError = message | ||
| 209 | return false | ||
| 184 | } catch let error as GitbayError { | 210 | } catch let error as GitbayError { |
| 185 | actionError = error.messageForTypedInput | 211 | actionError = error.messageForTypedInput |
| 186 | return false | 212 | return false |
gitbay/Views/Admin/AdminAccountView.swift +8 −3
| @@ -26,7 +26,11 @@ struct AdminAccountView: View { | |||
| 26 | } | 26 | } |
| 27 | } else if let loaded = model.state.value { | 27 | } else if let loaded = model.state.value { |
| 28 | header(loaded.detail) | 28 | header(loaded.detail) |
| 29 | if let limits = loaded.limits { limitsSection(limits) } | 29 | if let limits = loaded.limits { |
| 30 | limitsSection(limits) | ||
| 31 | } else if let why = loaded.limitsError { | ||
| 32 | Section("Limits") { GBNotice(why, .gbWarn) } | ||
| 33 | } | ||
| 30 | keysSection(loaded.detail) | 34 | keysSection(loaded.detail) |
| 31 | emailsSection(loaded.detail) | 35 | emailsSection(loaded.detail) |
| 32 | if !loaded.detail.orgs.isEmpty { orgsSection(loaded.detail) } | 36 | if !loaded.detail.orgs.isEmpty { orgsSection(loaded.detail) } |
| @@ -42,7 +46,7 @@ struct AdminAccountView: View { | |||
| 42 | .disabled(model.working) | 46 | .disabled(model.working) |
| 43 | .accessibilityIdentifier("admin-account-delete") | 47 | .accessibilityIdentifier("admin-account-delete") |
| 44 | } footer: { | 48 | } footer: { |
| 45 | Text("Only an account that anchors nothing: no repositories, organizations or authored threads. The refusal names what it still holds.") | 49 | Text("Only an account that anchors nothing: no repositories, no organization it is the only admin of, and no issues, comments or reviews. The refusal names what it still holds.") |
| 46 | } | 50 | } |
| 47 | } | 51 | } |
| 48 | } | 52 | } |
| @@ -94,7 +98,8 @@ struct AdminAccountView: View { | |||
| 94 | LabeledContent("Repositories", value: String(d.repos)) | 98 | LabeledContent("Repositories", value: String(d.repos)) |
| 95 | LabeledContent("Web sessions", value: String(d.webSessions)) | 99 | LabeledContent("Web sessions", value: String(d.webSessions)) |
| 96 | if let after = d.deleteAfter { | 100 | if let after = d.deleteAfter { |
| 97 | LabeledContent("Deletes after", value: after) | 101 | LabeledContent("Deletes after", value: (try? Date(after, strategy: .iso8601)) |
| 102 | .map { $0.formatted(date: .abbreviated, time: .shortened) } ?? after) | ||
| 98 | .foregroundStyle(Color.gbBad) | 103 | .foregroundStyle(Color.gbBad) |
| 99 | } | 104 | } |
| 100 | } | 105 | } |
gitbay/Views/Admin/AdminUsersView.swift +2 −1
| @@ -183,7 +183,8 @@ private struct CreateAccountSheet: View { | |||
| 183 | 183 | ||
| 184 | @State private var username = "" | 184 | @State private var username = "" |
| 185 | @State private var email = "" | 185 | @State private var email = "" |
| 186 | @State private var verified = true | 186 | /// Off: a mistyped address marked verified would take sign-in links. |
| 187 | @State private var verified = false | ||
| 187 | @State private var admin = false | 188 | @State private var admin = false |
| 188 | @State private var key = "" | 189 | @State private var key = "" |
| 189 | @State private var sent = false | 190 | @State private var sent = false |
gitbayTests/AdminAccountTests.swift +23
| @@ -67,6 +67,8 @@ struct AdminAccountTests { | |||
| 67 | #expect(OwnerLimits.flags(repos: "x", megabytes: "", orgs: nil) == nil) | 67 | #expect(OwnerLimits.flags(repos: "x", megabytes: "", orgs: nil) == nil) |
| 68 | #expect(OwnerLimits.flags(repos: "-1", megabytes: "", orgs: nil) == nil) | 68 | #expect(OwnerLimits.flags(repos: "-1", megabytes: "", orgs: nil) == nil) |
| 69 | #expect(OwnerLimits.flags(repos: "", megabytes: "", orgs: "") == []) | 69 | #expect(OwnerLimits.flags(repos: "", megabytes: "", orgs: "") == []) |
| 70 | // A cap too large to hold in bytes is refused, not a crash. | ||
| 71 | #expect(OwnerLimits.flags(repos: "", megabytes: "10000000000000", orgs: nil) == nil) | ||
| 70 | } | 72 | } |
| 71 | 73 | ||
| 72 | @Test func settingLimitsSendsTheChangedCaps() async throws { | 74 | @Test func settingLimitsSendsTheChangedCaps() async throws { |
| @@ -141,4 +143,25 @@ struct AdminCreateInviteTests { | |||
| 141 | #expect(try posted(stub).map(\.0) == [["admin", "invite", "--email", "fay@example.test"]]) | 143 | #expect(try posted(stub).map(\.0) == [["admin", "invite", "--email", "fay@example.test"]]) |
| 142 | #expect(note?.contains("abc123") == true) | 144 | #expect(note?.contains("abc123") == true) |
| 143 | } | 145 | } |
| 146 | |||
| 147 | @Test func aLimitsReadThatFailsSaysWhy() async throws { | ||
| 148 | let (client, stub) = try makeClient() | ||
| 149 | stub.enqueue(.init(status: 200, json: showJSON, match: "argv=show")) | ||
| 150 | stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"a read-scoped token cannot run that","exit_code":4}"#, match: "cmd")) | ||
| 151 | let model = AdminAccountViewModel(client: client, username: "other") | ||
| 152 | |||
| 153 | await model.load() | ||
| 154 | |||
| 155 | #expect(model.state.value?.limits == nil) | ||
| 156 | #expect(model.state.value?.limitsError == "a read-scoped token cannot run that") | ||
| 157 | } | ||
| 158 | |||
| 159 | @Test func aDeleteWhoseRetryFindsNothingIsDone() async throws { | ||
| 160 | let (client, stub) = try makeClient() | ||
| 161 | stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"cleanup failed","exit_code":1}"#, match: "cmd")) | ||
| 162 | stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no user \"other\"","exit_code":3}"#, match: "cmd")) | ||
| 163 | let model = AdminAccountViewModel(client: client, username: "other") | ||
| 164 | |||
| 165 | #expect(await model.delete()) | ||
| 166 | } | ||
| 144 | } | 167 | } |