Commit e0648f4c5f

e0648f4c5fc9ee2d5f0367b2e18973b4a4dc30aa

parent: 8b8a2634fc

Verified · cmc

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

admin: accounts, their actions, and who is an admin

admin user list with the web's five filters; promote, demote, disable
and enable with the web's rules for which a row offers; whoami's admin
flag for the account menu.

Layout: unified · split

gitbay/Admin/AdminModels.swift +58
@@ -179,3 +179,61 @@ nonisolated struct QueueLine: Sendable, Hashable, Identifiable {
179 let title: String 179 let title: String
180 let detail: String 180 let detail: String
181} 181}
182
183/// One row of `admin user list`.
184nonisolated struct AdminUser: Decodable, Sendable, Hashable, Identifiable {
185 let username: String
186 /// active, pending or disabled.
187 let state: String
188 let admin: Bool
189 let createdAt: Date
190 /// Absent for an account that has never signed in.
191 let lastSeen: Date?
192
193 enum CodingKeys: String, CodingKey {
194 case username, state, admin
195 case createdAt = "created_at"
196 case lastSeen = "last_seen"
197 }
198
199 var id: String { username }
200
201 /// The role button the web draws: Demote for an admin, Promote for
202 /// anyone else.
203 var roleAction: AdminAction { admin ? .demote : .promote }
204 /// Only an active account can be promoted; the web draws the button
205 /// disabled for the rest.
206 var canChangeRole: Bool { admin || state == "active" }
207 var accessAction: AdminAction { state == "disabled" ? .enable : .disable }
208}
209
210nonisolated enum AdminAction: String, Sendable, Hashable {
211 case promote, demote, disable, enable
212
213 /// Demote and disable take something away from the account, and the
214 /// web asks for the username typed before either.
215 var needsTypedName: Bool { self == .demote || self == .disable }
216
217 var title: String { rawValue.capitalized }
218
219 func argv(_ username: String) -> [String] { ["admin", "user", rawValue, username] }
220
221 func message(for username: String) -> String {
222 switch self {
223 case .promote: "\(username) becomes an instance admin."
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."
226 case .enable: "\(username) can sign in again."
227 }
228 }
229}
230
231/// The web's five filters over `admin user list`.
232nonisolated enum AdminUserFilter: String, CaseIterable, Identifiable, Sendable {
233 case all, active, pending, disabled, admin
234
235 var id: String { rawValue }
236 var label: String { self == .admin ? "Admins" : rawValue.capitalized }
237
238 func flags() -> [String] { self == .all ? [] : ["--state", rawValue] }
239}
gitbay/Admin/AdminViewModels.swift added +98
@@ -0,0 +1,98 @@
1import Foundation
2import Observation
3
4/// Whether the signed-in account is an instance admin, from `whoami`. A
5/// failed read counts as not: the account menu then leaves Admin out.
6enum AdminCheck {
7
8 nonisolated private struct Who: Decodable, Sendable {
9 let admin: Bool?
10 }
11
12 static func isAdmin(_ client: GitbayClient) async -> Bool {
13 ((try? await client.read(["whoami"], as: Who.self))?.admin) ?? false
14 }
15}
16
17/// The admin screen: `dashboard`'s server build and worker queues, the
18/// read the web's /admin page makes.
19@Observable
20@MainActor
21final class AdminOverviewViewModel {
22
23 nonisolated struct Overview: Sendable, Hashable {
24 let commit: String?
25 let sections: [QueueSection]
26 }
27
28 private(set) var state: LoadState<Overview> = .loading
29
30 private let client: GitbayClient
31
32 init(client: GitbayClient) {
33 self.client = client
34 }
35
36 func load() async {
37 do {
38 let dashboard = try await client.read(["dashboard"], as: AdminDashboard.self)
39 guard let queues = dashboard.queues else {
40 state = .empty("Only instance admins can see the worker queues.")
41 return
42 }
43 state = .loaded(Overview(commit: dashboard.server?.commit, sections: queues.sections()))
44 } catch {
45 state = .from(error)
46 }
47 }
48}
49
50/// `admin user list`, narrowed by the web's five filters, and the four
51/// writes /admin/users makes per row.
52@Observable
53@MainActor
54final class AdminUsersViewModel {
55
56 let list: PagedListModel<AdminUser>
57 var filter = AdminUserFilter.all {
58 didSet {
59 guard filter != oldValue else { return }
60 list.argv = ["admin", "user", "list"] + filter.flags()
61 reloadTask?.cancel()
62 reloadTask = Task { await list.reload() }
63 }
64 }
65 private var reloadTask: Task<Void, Never>?
66 private(set) var actionError: String?
67 private(set) var working = false
68
69 private let client: GitbayClient
70
71 init(client: GitbayClient) {
72 self.client = client
73 list = PagedListModel(
74 client: client,
75 argv: ["admin", "user", "list"],
76 emptyMessage: "No account matches"
77 )
78 }
79
80 func load() async {
81 reloadTask?.cancel()
82 await list.reload()
83 }
84
85 func perform(_ action: AdminAction, on username: String) async {
86 working = true
87 actionError = nil
88 defer { working = false }
89 do {
90 try await client.run(action.argv(username))
91 await list.reload()
92 } catch let error as GitbayError {
93 actionError = error.userFacingMessage
94 } catch {
95 actionError = GitbayError.transport(error).userFacingMessage
96 }
97 }
98}
gitbayTests/AdminTests.swift +185
@@ -103,3 +103,188 @@ struct AdminQueueSectionTests {
103 #expect(AdminQueues.relative("soon") == "soon") 103 #expect(AdminQueues.relative("soon") == "soon")
104 } 104 }
105} 105}
106
107private func user(_ name: String, state: String, admin: Bool = false) throws -> AdminUser {
108 let json = """
109 {"username":"\(name)","state":"\(state)","admin":\(admin),\
110 "created_at":"2026-08-28T21:55:00.752Z"}
111 """
112 return try GitbayClient.decoder().decode(AdminUser.self, from: Data(json.utf8))
113}
114
115struct AdminUserActionTests {
116
117 @Test func anAdminIsDemotedAndCanBeDisabled() throws {
118 let u = try user("cmc", state: "active", admin: true)
119 #expect(u.roleAction == .demote)
120 #expect(u.canChangeRole)
121 #expect(u.accessAction == .disable)
122 }
123
124 @Test func anActiveAccountCanBePromoted() throws {
125 let u = try user("blotter-ci", state: "active")
126 #expect(u.roleAction == .promote)
127 #expect(u.canChangeRole)
128 }
129
130 /// The web draws Promote on every non-admin row but disables it for
131 /// an account that is not active.
132 @Test func onlyAnActiveAccountCanBePromoted() throws {
133 #expect(try user("new", state: "pending").canChangeRole == false)
134 #expect(try user("gone", state: "disabled").canChangeRole == false)
135 }
136
137 @Test func aDisabledAccountIsEnabledAndAnyOtherDisabled() throws {
138 #expect(try user("gone", state: "disabled").accessAction == .enable)
139 #expect(try user("new", state: "pending").accessAction == .disable)
140 }
141
142 @Test func demoteAndDisableAskForTheTypedName() {
143 #expect(AdminAction.demote.needsTypedName)
144 #expect(AdminAction.disable.needsTypedName)
145 #expect(!AdminAction.promote.needsTypedName)
146 #expect(!AdminAction.enable.needsTypedName)
147 }
148
149 @Test func eachActionIsItsAdminUserCommand() {
150 #expect(AdminAction.promote.argv("x") == ["admin", "user", "promote", "x"])
151 #expect(AdminAction.demote.argv("x") == ["admin", "user", "demote", "x"])
152 #expect(AdminAction.disable.argv("x") == ["admin", "user", "disable", "x"])
153 #expect(AdminAction.enable.argv("x") == ["admin", "user", "enable", "x"])
154 }
155
156 @Test func theFiltersAreTheWebsFive() {
157 #expect(AdminUserFilter.allCases.map(\.label) == ["All", "Active", "Pending", "Disabled", "Admins"])
158 #expect(AdminUserFilter.all.flags().isEmpty)
159 #expect(AdminUserFilter.admin.flags() == ["--state", "admin"])
160 }
161}
162
163@MainActor
164struct AdminCheckTests {
165
166 @Test func whoamisAdminFlagDecides() async throws {
167 let (client, stub) = try makeClient()
168 stub.enqueue(.init(status: 200, json:
169 #"{"protocol_version":1,"data":{"username":"cmc","admin":true,"key_scope":"full"}}"#))
170 #expect(await AdminCheck.isAdmin(client))
171 #expect(stub.seen.first?.url.query() == "argv=whoami")
172 }
173
174 @Test func aNonAdminIsNot() async throws {
175 let (client, stub) = try makeClient()
176 stub.enqueue(.init(status: 200, json:
177 #"{"protocol_version":1,"data":{"username":"ios-smoke","admin":false}}"#))
178 #expect(await AdminCheck.isAdmin(client) == false)
179 }
180
181 /// The menu just leaves the entry out when the read fails.
182 @Test func aFailedReadIsNotAnAdmin() async throws {
183 let (client, stub) = try makeClient()
184 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
185 #expect(await AdminCheck.isAdmin(client) == false)
186 }
187}
188
189@MainActor
190struct AdminOverviewViewModelTests {
191
192 @Test func readsDashboardIntoSections() async throws {
193 let (client, stub) = try makeClient()
194 stub.enqueue(.init(status: 200, json: adminDashboardJSON))
195 let model = AdminOverviewViewModel(client: client)
196
197 await model.load()
198
199 let overview = try #require(model.state.value)
200 #expect(overview.commit == "db7503f06f11")
201 #expect(overview.sections.count == 6)
202 #expect(stub.seen.first?.url.query() == "argv=dashboard")
203 }
204
205 @Test func aDashboardWithoutQueuesIsAnEmptyState() async throws {
206 let (client, stub) = try makeClient()
207 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"unread":0},"exit_code":0}"#))
208 let model = AdminOverviewViewModel(client: client)
209
210 await model.load()
211
212 guard case .empty(let message) = model.state else {
213 Issue.record("expected .empty, got \(model.state)")
214 return
215 }
216 #expect(message == "Only instance admins can see the worker queues.")
217 }
218}
219
220private let userPageJSON = """
221 {"protocol_version":1,"data":{"items":[\
222 {"username":"apple-review","state":"active","admin":false,"created_at":"2026-08-28T21:55:00.752Z",\
223 "last_seen":"2026-09-21T07:05:26.201Z"},\
224 {"username":"bhargavkk","state":"pending","admin":false,"created_at":"2026-09-11T01:50:04.063Z"}],\
225 "next":"YWRtaW4tdXNlcjpiaGFyZ2F2a2s"},"exit_code":0}
226 """
227
228@MainActor
229struct AdminUsersViewModelTests {
230
231 @Test func listsEveryAccountByDefault() async throws {
232 let (client, stub) = try makeClient()
233 stub.enqueue(.init(status: 200, json: userPageJSON))
234 let model = AdminUsersViewModel(client: client)
235
236 await model.load()
237
238 let users = try #require(model.list.state.value)
239 #expect(users.map(\.username) == ["apple-review", "bhargavkk"])
240 #expect(users[0].lastSeen != nil)
241 #expect(users[1].lastSeen == nil)
242 #expect(model.list.hasMore)
243 #expect(stub.seen.first?.url.query() == "argv=admin&argv=user&argv=list&argv=--limit&argv=50")
244 }
245
246 @Test func aFilterReloadsWithItsState() async throws {
247 let (client, stub) = try makeClient()
248 stub.enqueue(.init(status: 200, json: userPageJSON))
249 let model = AdminUsersViewModel(client: client)
250 await model.load()
251
252 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#))
253 model.filter = .disabled
254 await until {
255 if case .empty = model.list.state { return true }
256 return false
257 }
258
259 #expect(stub.seen.last?.url.query()
260 == "argv=admin&argv=user&argv=list&argv=--state&argv=disabled&argv=--limit&argv=50")
261 guard case .empty(let message) = model.list.state else { return }
262 #expect(message == "No account matches")
263 }
264
265 @Test func anActionSendsItsCommandAndReloads() async throws {
266 let (client, stub) = try makeClient()
267 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "cmd"))
268 stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list"))
269 let model = AdminUsersViewModel(client: client)
270
271 await model.perform(.disable, on: "bhargavkk")
272
273 let write = try #require(stub.seen.first { $0.method == "POST" })
274 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
275 #expect(body["argv"] as? [String] == ["admin", "user", "disable", "bhargavkk"])
276 #expect(model.actionError == nil)
277 #expect(model.list.state.value?.count == 2)
278 }
279
280 @Test func aRefusalIsShownVerbatim() async throws {
281 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"))
284 let model = AdminUsersViewModel(client: client)
285
286 await model.perform(.demote, on: "cmc")
287
288 #expect(model.actionError == "cmc is the last admin")
289 }
290}