Commit af09603805

af09603805feaa815ace9a40f43fdc2458e53e71

parent: 6887325f24

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-22 23:23 UTC

admin: reload after every action; match the web's details

perform() reloaded only on success, leaving a stale row behind a
refusal (e.g. the account changed state meanwhile); it now reloads
after both success and failure, through reloadTask so a filter change
cannot race it. Typed-name confirmation trims whitespace, like the
web. AdminUsersViewModel gets loadIfNeeded() like the other list view
models, so returning to the screen keeps its loaded page. The disable
message now says what the server actually does (signs out of the web,
revokes API tokens; SSH stays refused until re-enabled) rather than
"until enabled" for everything. The admin chip is neutral like the
web's plain chip, and the row menu carries an accessibility label.

Tests: the refusal test uses the server's real last-admin failure
(HTTP 500, exit 1, retried once) and also checks the reload it
triggers; Envelope is renamed DashboardEnvelope to say what it is.
Added coverage for a refused action still reloading the list and for
loadIfNeeded() reading only once.

Layout: unified · split

gitbay/Admin/AdminModels.swift +1 −1
@@ -222,7 +222,7 @@ nonisolated enum AdminAction: String, Sendable, Hashable {
222222 switch self {
223223 case .promote: "\(username) becomes an instance admin."
224224 case .demote: "\(username) stops being an instance admin. Type the username to confirm."
225 case .disable: "SSH, web sessions and API tokens for \(username) are refused until the account is enabled. Type the username to confirm."
225 case .disable: "Signs \(username) out of the web and revokes their API tokens; SSH is refused until the account is enabled. Type the username to confirm."
226226 case .enable: "\(username) can sign in again."
227227 }
228228 }
gitbay/Admin/AdminViewModels.swift +9 −1
@@ -82,17 +82,25 @@ final class AdminUsersViewModel {
8282 await list.reload()
8383 }
8484
85 /// The list's `.task`: reads only while nothing is shown yet.
86 func loadIfNeeded() async {
87 guard list.needsLoad else { return }
88 await load()
89 }
90
8591 func perform(_ action: AdminAction, on username: String) async {
8692 working = true
8793 actionError = nil
8894 defer { working = false }
8995 do {
9096 try await client.run(action.argv(username))
91 await list.reload()
9297 } catch let error as GitbayError {
9398 actionError = error.userFacingMessage
9499 } catch {
95100 actionError = GitbayError.transport(error).userFacingMessage
96101 }
102 reloadTask?.cancel()
103 reloadTask = Task { await list.reload() }
104 await reloadTask?.value
97105 }
98106}
gitbay/Views/Admin/AdminUsersView.swift +5 −3
@@ -43,7 +43,7 @@ struct AdminUsersView: View {
4343 .overlay { LoadStateOverlay(state: model.list.state) }
4444 .navigationTitle("Accounts")
4545 .navigationBarTitleDisplayMode(.inline)
46 .task { await model.load() }
46 .task { await model.loadIfNeeded() }
4747 .refreshable { await model.load() }
4848 .alert(
4949 pending.map { "\($0.action.title) \($0.username)?" } ?? "",
@@ -58,7 +58,8 @@ struct AdminUsersView: View {
5858 Button(pending.action.title, role: pending.action.needsTypedName ? .destructive : nil) {
5959 Task { await model.perform(pending.action, on: pending.username) }
6060 }
61 .disabled(pending.action.needsTypedName && typed != pending.username)
61 .disabled(pending.action.needsTypedName
62 && typed.trimmingCharacters(in: .whitespaces) != pending.username)
6263 Button("Cancel", role: .cancel) {}
6364 } message: { pending in
6465 Text(pending.action.message(for: pending.username))
@@ -72,7 +73,7 @@ struct AdminUsersView: View {
7273 Text(user.username)
7374 .font(.gbSans(.subheadline).weight(.medium))
7475 if user.admin {
75 GBChip("admin", .gbAccent)
76 GBChip("admin", .secondary)
7677 }
7778 GBChip(user.state, stateColor(user.state))
7879 }
@@ -93,6 +94,7 @@ struct AdminUsersView: View {
9394 Image(systemName: "ellipsis.circle")
9495 }
9596 .accessibilityIdentifier("admin-user-menu-\(user.username)")
97 .accessibilityLabel("Actions for \(user.username)")
9698 }
9799 }
98100
gitbayTests/AdminTests.swift +40 −6
@@ -39,11 +39,11 @@ private let adminDashboardJSON = """
3939 }},"exit_code":0}
4040 """
4141
42nonisolated private struct Envelope: Decodable { let data: AdminDashboard }
42nonisolated private struct DashboardEnvelope: Decodable { let data: AdminDashboard }
4343
4444private func sections() throws -> [QueueSection] {
4545 let dashboard = try GitbayClient.decoder()
46 .decode(Envelope.self, from: Data(adminDashboardJSON.utf8)).data
46 .decode(DashboardEnvelope.self, from: Data(adminDashboardJSON.utf8)).data
4747 let queues = try #require(dashboard.queues)
4848 // A fixed stand-in for relative time keeps the text deterministic.
4949 return queues.sections(when: { "at \($0)" })
@@ -94,7 +94,7 @@ struct AdminQueueSectionTests {
9494 /// Everyone else's dashboard omits both blocks.
9595 @Test func aNonAdminDashboardHasNoQueues() throws {
9696 let json = #"{"protocol_version":1,"data":{"unread":3},"exit_code":0}"#
97 let dashboard = try GitbayClient.decoder().decode(Envelope.self, from: Data(json.utf8)).data
97 let dashboard = try GitbayClient.decoder().decode(DashboardEnvelope.self, from: Data(json.utf8)).data
9898 #expect(dashboard.queues == nil)
9999 #expect(dashboard.server == nil)
100100 }
@@ -243,6 +243,18 @@ struct AdminUsersViewModelTests {
243243 #expect(stub.seen.first?.url.query() == "argv=admin&argv=user&argv=list&argv=--limit&argv=50")
244244 }
245245
246 @Test func loadIfNeededOnlyReadsOnce() async throws {
247 let (client, stub) = try makeClient()
248 stub.enqueue(.init(status: 200, json: userPageJSON))
249 let model = AdminUsersViewModel(client: client)
250
251 await model.loadIfNeeded()
252 await model.loadIfNeeded()
253
254 #expect(stub.seen.count == 1)
255 #expect(stub.seen.first?.url.query() == "argv=admin&argv=user&argv=list&argv=--limit&argv=50")
256 }
257
246258 @Test func aFilterReloadsWithItsState() async throws {
247259 let (client, stub) = try makeClient()
248260 stub.enqueue(.init(status: 200, json: userPageJSON))
@@ -277,14 +289,36 @@ struct AdminUsersViewModelTests {
277289 #expect(model.list.state.value?.count == 2)
278290 }
279291
292 /// The last-admin refusal is a real server failure (exit 1, HTTP 500),
293 /// which the client retries once before surfacing it.
280294 @Test func aRefusalIsShownVerbatim() async throws {
281295 let (client, stub) = try makeClient()
282 stub.enqueue(.init(status: 200, json:
283 #"{"protocol_version":1,"error":"cmc is the last admin","exit_code":4}"#, match: "cmd"))
296 let refusal = StubProtocol.Stub(status: 500, json:
297 #"{"protocol_version":1,"error":"that is the only instance admin; promote someone else first","exit_code":1}"#,
298 match: "cmd")
299 stub.enqueue(refusal)
300 stub.enqueue(refusal)
301 stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list"))
284302 let model = AdminUsersViewModel(client: client)
285303
286304 await model.perform(.demote, on: "cmc")
287305
288 #expect(model.actionError == "cmc is the last admin")
306 #expect(model.actionError == "that is the only instance admin; promote someone else first")
307 #expect(model.list.state.value?.count == 2)
308 }
309
310 /// A refusal must still reload the list, so a stale row does not
311 /// linger after the account changed state meanwhile.
312 @Test func aRefusedActionStillReloadsTheList() async throws {
313 let (client, stub) = try makeClient()
314 stub.enqueue(.init(status: 403, json:
315 #"{"protocol_version":1,"error":"account changed state","exit_code":4}"#, match: "cmd"))
316 stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list"))
317 let model = AdminUsersViewModel(client: client)
318
319 await model.perform(.disable, on: "bhargavkk")
320
321 #expect(model.actionError == "account changed state")
322 #expect(stub.seen.contains { $0.url.query()?.contains("argv=list") == true })
289323 }
290324}