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 {
222 switch self { 222 switch self {
223 case .promote: "\(username) becomes an instance admin." 223 case .promote: "\(username) becomes an instance admin."
224 case .demote: "\(username) stops being an instance admin. Type the username to confirm." 224 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."
226 case .enable: "\(username) can sign in again." 226 case .enable: "\(username) can sign in again."
227 } 227 }
228 } 228 }
gitbay/Admin/AdminViewModels.swift +9 −1
@@ -82,17 +82,25 @@ final class AdminUsersViewModel {
82 await list.reload() 82 await list.reload()
83 } 83 }
84 84
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
85 func perform(_ action: AdminAction, on username: String) async { 91 func perform(_ action: AdminAction, on username: String) async {
86 working = true 92 working = true
87 actionError = nil 93 actionError = nil
88 defer { working = false } 94 defer { working = false }
89 do { 95 do {
90 try await client.run(action.argv(username)) 96 try await client.run(action.argv(username))
91 await list.reload()
92 } catch let error as GitbayError { 97 } catch let error as GitbayError {
93 actionError = error.userFacingMessage 98 actionError = error.userFacingMessage
94 } catch { 99 } catch {
95 actionError = GitbayError.transport(error).userFacingMessage 100 actionError = GitbayError.transport(error).userFacingMessage
96 } 101 }
102 reloadTask?.cancel()
103 reloadTask = Task { await list.reload() }
104 await reloadTask?.value
97 } 105 }
98} 106}
gitbay/Views/Admin/AdminUsersView.swift +5 −3
@@ -43,7 +43,7 @@ struct AdminUsersView: View {
43 .overlay { LoadStateOverlay(state: model.list.state) } 43 .overlay { LoadStateOverlay(state: model.list.state) }
44 .navigationTitle("Accounts") 44 .navigationTitle("Accounts")
45 .navigationBarTitleDisplayMode(.inline) 45 .navigationBarTitleDisplayMode(.inline)
46 .task { await model.load() } 46 .task { await model.loadIfNeeded() }
47 .refreshable { await model.load() } 47 .refreshable { await model.load() }
48 .alert( 48 .alert(
49 pending.map { "\($0.action.title) \($0.username)?" } ?? "", 49 pending.map { "\($0.action.title) \($0.username)?" } ?? "",
@@ -58,7 +58,8 @@ struct AdminUsersView: View {
58 Button(pending.action.title, role: pending.action.needsTypedName ? .destructive : nil) { 58 Button(pending.action.title, role: pending.action.needsTypedName ? .destructive : nil) {
59 Task { await model.perform(pending.action, on: pending.username) } 59 Task { await model.perform(pending.action, on: pending.username) }
60 } 60 }
61 .disabled(pending.action.needsTypedName && typed != pending.username) 61 .disabled(pending.action.needsTypedName
62 && typed.trimmingCharacters(in: .whitespaces) != pending.username)
62 Button("Cancel", role: .cancel) {} 63 Button("Cancel", role: .cancel) {}
63 } message: { pending in 64 } message: { pending in
64 Text(pending.action.message(for: pending.username)) 65 Text(pending.action.message(for: pending.username))
@@ -72,7 +73,7 @@ struct AdminUsersView: View {
72 Text(user.username) 73 Text(user.username)
73 .font(.gbSans(.subheadline).weight(.medium)) 74 .font(.gbSans(.subheadline).weight(.medium))
74 if user.admin { 75 if user.admin {
75 GBChip("admin", .gbAccent) 76 GBChip("admin", .secondary)
76 } 77 }
77 GBChip(user.state, stateColor(user.state)) 78 GBChip(user.state, stateColor(user.state))
78 } 79 }
@@ -93,6 +94,7 @@ struct AdminUsersView: View {
93 Image(systemName: "ellipsis.circle") 94 Image(systemName: "ellipsis.circle")
94 } 95 }
95 .accessibilityIdentifier("admin-user-menu-\(user.username)") 96 .accessibilityIdentifier("admin-user-menu-\(user.username)")
97 .accessibilityLabel("Actions for \(user.username)")
96 } 98 }
97 } 99 }
98 100
gitbayTests/AdminTests.swift +40 −6
@@ -39,11 +39,11 @@ private let adminDashboardJSON = """
39 }},"exit_code":0} 39 }},"exit_code":0}
40 """ 40 """
41 41
42nonisolated private struct Envelope: Decodable { let data: AdminDashboard } 42nonisolated private struct DashboardEnvelope: Decodable { let data: AdminDashboard }
43 43
44private func sections() throws -> [QueueSection] { 44private func sections() throws -> [QueueSection] {
45 let dashboard = try GitbayClient.decoder() 45 let dashboard = try GitbayClient.decoder()
46 .decode(Envelope.self, from: Data(adminDashboardJSON.utf8)).data 46 .decode(DashboardEnvelope.self, from: Data(adminDashboardJSON.utf8)).data
47 let queues = try #require(dashboard.queues) 47 let queues = try #require(dashboard.queues)
48 // A fixed stand-in for relative time keeps the text deterministic. 48 // A fixed stand-in for relative time keeps the text deterministic.
49 return queues.sections(when: { "at \($0)" }) 49 return queues.sections(when: { "at \($0)" })
@@ -94,7 +94,7 @@ struct AdminQueueSectionTests {
94 /// Everyone else's dashboard omits both blocks. 94 /// Everyone else's dashboard omits both blocks.
95 @Test func aNonAdminDashboardHasNoQueues() throws { 95 @Test func aNonAdminDashboardHasNoQueues() throws {
96 let json = #"{"protocol_version":1,"data":{"unread":3},"exit_code":0}"# 96 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
98 #expect(dashboard.queues == nil) 98 #expect(dashboard.queues == nil)
99 #expect(dashboard.server == nil) 99 #expect(dashboard.server == nil)
100 } 100 }
@@ -243,6 +243,18 @@ struct AdminUsersViewModelTests {
243 #expect(stub.seen.first?.url.query() == "argv=admin&argv=user&argv=list&argv=--limit&argv=50") 243 #expect(stub.seen.first?.url.query() == "argv=admin&argv=user&argv=list&argv=--limit&argv=50")
244 } 244 }
245 245
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
246 @Test func aFilterReloadsWithItsState() async throws { 258 @Test func aFilterReloadsWithItsState() async throws {
247 let (client, stub) = try makeClient() 259 let (client, stub) = try makeClient()
248 stub.enqueue(.init(status: 200, json: userPageJSON)) 260 stub.enqueue(.init(status: 200, json: userPageJSON))
@@ -277,14 +289,36 @@ struct AdminUsersViewModelTests {
277 #expect(model.list.state.value?.count == 2) 289 #expect(model.list.state.value?.count == 2)
278 } 290 }
279 291
292 /// The last-admin refusal is a real server failure (exit 1, HTTP 500),
293 /// which the client retries once before surfacing it.
280 @Test func aRefusalIsShownVerbatim() async throws { 294 @Test func aRefusalIsShownVerbatim() async throws {
281 let (client, stub) = try makeClient() 295 let (client, stub) = try makeClient()
282 stub.enqueue(.init(status: 200, json: 296 let refusal = StubProtocol.Stub(status: 500, json:
283 #"{"protocol_version":1,"error":"cmc is the last admin","exit_code":4}"#, match: "cmd")) 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"))
284 let model = AdminUsersViewModel(client: client) 302 let model = AdminUsersViewModel(client: client)
285 303
286 await model.perform(.demote, on: "cmc") 304 await model.perform(.demote, on: "cmc")
287 305
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 })
289 } 323 }
290} 324}