Commit 34f139b808
Verified · cmc
Layout: unified · split
docs/superpowers/plans/2026-09-22-1.6.0-admin.md added +1187
| @@ -0,0 +1,1187 @@ | ||
| 1 | # 1.6.0 Admin Implementation Plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** An instance admin can do from the app what the web's `/admin` and `/admin/users` pages do: read every worker queue and the server build, and list, filter, promote, demote, disable and enable accounts. | |
| 6 | ||
| 7 | **Architecture:** Pure models in `gitbay/Admin/AdminModels.swift` decode `dashboard`'s admin-only `queues`/`server` blocks and `admin user list` rows, and turn them into the web's sections and per-row actions. Two view models read and write through the existing `GitbayClient`; the accounts list pages through `PagedListModel`. The account menu reads `whoami`'s `admin` flag to decide whether to offer the screen. Nothing beyond what the two web pages carry. | |
| 8 | ||
| 9 | **Tech Stack:** Swift 6 language mode with default `MainActor` isolation, SwiftUI, Swift Testing, XCUITest. iOS 18.0 minimum, iOS 26.5 SDK. | |
| 10 | ||
| 11 | **Spec:** `docs/superpowers/specs/2026-09-22-parity-followup-design.md` (section "1.6.0: admin") | |
| 12 | ||
| 13 | ## Global Constraints | |
| 14 | ||
| 15 | - **Never attribute anything to an assistant or model** — not in commits, code comments, MR bodies, or docs. No `Co-Authored-By` trailer. This is absolute. | |
| 16 | - **Commits are signed.** `commit.gpgsign` is on; do not pass `--no-gpg-sign`. | |
| 17 | - **Never push to `main`. Never merge.** One branch, `admin`, cut from `main` after 1.5.0 has merged. Task 5 is the version bump and waits for the user to merge it. | |
| 18 | - **Never run an admin write against gitbay.org from a test or by hand.** Promote, demote, disable and enable act on real people's accounts. Unit tests stub every request; the live suite only checks that a non-admin is not offered the screen. | |
| 19 | - **Match the web, no more.** No account show, invite, runners, repository administration, audit log or statistics. Promote only an active account; typed-name confirmation for demote and disable only. | |
| 20 | - **Swift 6, default `MainActor` isolation, in the test target too.** Wire models, pure section types and any `Decodable` struct declared in a test are `nonisolated`, or their conformance is main-actor isolated and `JSONDecoder` refuses it. | |
| 21 | - **File-system-synchronized groups.** New `.swift` files under `gitbay/` and `gitbayTests/` are picked up automatically. Do not edit `project.pbxproj` except for the version lines in Task 5. | |
| 22 | - **Reads are `GET /api/v1/read?argv=…`; writes are `POST /api/v1/cmd`.** A stub's `match:` is a URL substring. Assert writes by filtering `stub.seen` on `method == "POST"`. A paged read's envelope is `{"data":{"items":[…],"next":"…"}}`. | |
| 23 | - **Design tokens only.** `.gbSans`/`.gbMono` fonts, `GBChip` for chips, `GBNotice` for errors, `gb` palette colours. Run the design greps in Task 4. | |
| 24 | - Comments explain *why*, in plain direct English, at the density of the surrounding code. No before/after commentary. | |
| 25 | - **Run the whole unit target, and read the count.** | |
| 26 | ||
| 27 | ```bash | |
| 28 | cd /Users/cmc/git/krz/gitbay-ios && rm -rf /tmp/gb.xcresult && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | |
| 29 | -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' -only-testing:gitbayTests \ | |
| 30 | -resultBundlePath /tmp/gb.xcresult test 2>&1 | grep -E "error:|failed|TEST (SUCCEEDED|FAILED)" | tail -20 | |
| 31 | xcrun xcresulttool get test-results summary --path /tmp/gb.xcresult --format json \ | |
| 32 | | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['result'], d['totalTestCount'], d['failedTests'])" | |
| 33 | ``` | |
| 34 | ||
| 35 | "Run the unit target" below means these two commands. Record the count before Task 1 as `BASE`. | |
| 36 | ||
| 37 | --- | |
| 38 | ||
| 39 | ### Task 1: Queue sections, as the web lays them out | |
| 40 | ||
| 41 | **Files:** | |
| 42 | - Create: `gitbay/Admin/AdminModels.swift` | |
| 43 | - Create: `gitbayTests/AdminTests.swift` | |
| 44 | ||
| 45 | **Interfaces:** | |
| 46 | - Produces: | |
| 47 | - `AdminDashboard: Decodable` with `queues: AdminQueues?`, `server: AdminDashboard.Server?` (`commit: String?`). | |
| 48 | - `AdminQueues: Decodable` with `webhooks`, `mail`, `push`, `mirrors`, `builds`, `deps: AdminQueues.Queue`, and `func sections(when: (String) -> String = AdminQueues.relative) -> [QueueSection]`. | |
| 49 | - `static func AdminQueues.relative(_ timestamp: String) -> String`. | |
| 50 | - `QueueSection` (`id: String`, `heading: String`, `count: Int`, `summary: String?`, `rows: [QueueLine]`, `empty: String`); `QueueLine` (`id: Int`, `title: String`, `detail: String`). | |
| 51 | ||
| 52 | - [ ] **Step 1: Branch** | |
| 53 | ||
| 54 | ```bash | |
| 55 | cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c admin | |
| 56 | ``` | |
| 57 | ||
| 58 | Run the unit target and record the count as `BASE`. | |
| 59 | ||
| 60 | - [ ] **Step 2: Write the failing tests** | |
| 61 | ||
| 62 | Create `gitbayTests/AdminTests.swift`: | |
| 63 | ||
| 64 | ```swift | |
| 65 | import Foundation | |
| 66 | import Testing | |
| 67 | @testable import gitbay | |
| 68 | ||
| 69 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 70 | let box = StubProtocol.box() | |
| 71 | let client = GitbayClient( | |
| 72 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 73 | token: "test-token", | |
| 74 | session: box.session() | |
| 75 | ) | |
| 76 | return (client, box) | |
| 77 | } | |
| 78 | ||
| 79 | /// `dashboard` as an admin sees it, trimmed to the admin blocks, with | |
| 80 | /// one row in every queue that lists rows. | |
| 81 | private let adminDashboardJSON = """ | |
| 82 | {"protocol_version":1,"data":{"unread":0,\ | |
| 83 | "server":{"commit":"db7503f06f11"},\ | |
| 84 | "queues":{\ | |
| 85 | "webhooks":{"pending":2,"retrying":1,"failed":1,"oldest_pending":"2026-09-22T04:00:00Z","items":[\ | |
| 86 | {"id":9,"repo":"krz/gitbay","url":"https://ci.example.com/hook","attempts":5,\ | |
| 87 | "last_status":502,"last_error":"bad gateway","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T03:00:00Z"}]},\ | |
| 88 | "mail":{"pending":0,"retrying":1,"failed":0,"items":[\ | |
| 89 | {"id":4,"recipient":"a@example.com","subject":"krz/gitbay#12","attempts":2,\ | |
| 90 | "last_error":"dial tcp: timeout","created_at":"2026-09-22T03:00:00Z"}]},\ | |
| 91 | "mirrors":{"dirty":3,"errors":1,"items":[\ | |
| 92 | {"id":1,"repo":"krz/solar","direction":"push","url":"https://github.com/x/solar",\ | |
| 93 | "last_error":"auth failed"}]},\ | |
| 94 | "builds":{"pending":1,"running":1,"items":[\ | |
| 95 | {"repo":"krz/gitbay","number":1480,"job":"test","status":"running",\ | |
| 96 | "created_at":"2026-09-22T04:40:00Z","started_at":"2026-09-22T04:41:00Z"},\ | |
| 97 | {"repo":"krz/gitbay","number":1481,"job":"build","status":"pending",\ | |
| 98 | "created_at":"2026-09-22T04:42:00Z","started_at":""}]},\ | |
| 99 | "deps":{"errors":0,"items":[]},\ | |
| 100 | "push":{"pending":0,"retrying":0,"failed":1,"items":[\ | |
| 101 | {"id":7,"device_id":3,"title":"krz/gitbay!450 merged","attempts":3,\ | |
| 102 | "last_error":"BadDeviceToken","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T04:00:00Z"}]}\ | |
| 103 | }},"exit_code":0} | |
| 104 | """ | |
| 105 | ||
| 106 | nonisolated private struct Envelope: Decodable { let data: AdminDashboard } | |
| 107 | ||
| 108 | private func sections() throws -> [QueueSection] { | |
| 109 | let dashboard = try GitbayClient.decoder() | |
| 110 | .decode(Envelope.self, from: Data(adminDashboardJSON.utf8)).data | |
| 111 | let queues = try #require(dashboard.queues) | |
| 112 | // A fixed stand-in for relative time keeps the text deterministic. | |
| 113 | return queues.sections(when: { "at \($0)" }) | |
| 114 | } | |
| 115 | ||
| 116 | struct AdminQueueSectionTests { | |
| 117 | ||
| 118 | @Test func sixSectionsInTheWebsOrder() throws { | |
| 119 | let all = try sections() | |
| 120 | #expect(all.map(\.heading) == [ | |
| 121 | "Webhook deliveries", "Mail", "Push", "Mirrors", "Builds", "Dependency checks", | |
| 122 | ]) | |
| 123 | #expect(all.map(\.count) == [2, 0, 0, 1, 1, 0]) | |
| 124 | } | |
| 125 | ||
| 126 | @Test func countsLinesUseTheWebsWords() throws { | |
| 127 | let all = try sections() | |
| 128 | #expect(all[0].summary == "2 pending · 1 retrying · 1 dead-lettered · oldest pending at 2026-09-22T04:00:00Z") | |
| 129 | #expect(all[1].summary == "0 pending · 1 retrying · 0 failed") | |
| 130 | #expect(all[3].summary == "3 waiting for a sync · 1 with a failed last sync") | |
| 131 | #expect(all[4].summary == "1 pending · 1 running") | |
| 132 | #expect(all[5].summary == nil) | |
| 133 | } | |
| 134 | ||
| 135 | @Test func rowsSayWhatTheWebsTableColumnsSay() throws { | |
| 136 | let all = try sections() | |
| 137 | #expect(all[0].rows.map(\.title) == ["krz/gitbay"]) | |
| 138 | #expect(all[0].rows[0].detail | |
| 139 | == "https://ci.example.com/hook · 5 attempts · dead-lettered at 2026-09-22T05:00:00Z · 502 bad gateway") | |
| 140 | #expect(all[1].rows[0].title == "a@example.com") | |
| 141 | #expect(all[1].rows[0].detail == "krz/gitbay#12 · 2 attempts · retrying · dial tcp: timeout") | |
| 142 | #expect(all[2].rows[0].title == "device 3") | |
| 143 | #expect(all[2].rows[0].detail | |
| 144 | == "krz/gitbay!450 merged · 3 attempts · failed at 2026-09-22T05:00:00Z · BadDeviceToken") | |
| 145 | #expect(all[3].rows[0].detail == "push · https://github.com/x/solar · never synced · auth failed") | |
| 146 | // A pending build has started_at "" and reports since it was queued. | |
| 147 | #expect(all[4].rows.map(\.title) == ["krz/gitbay #1480", "krz/gitbay #1481"]) | |
| 148 | #expect(all[4].rows[0].detail == "test · running · since at 2026-09-22T04:41:00Z") | |
| 149 | #expect(all[4].rows[1].detail == "build · pending · since at 2026-09-22T04:42:00Z") | |
| 150 | } | |
| 151 | ||
| 152 | @Test func anEmptyQueueSaysSoInTheWebsWords() throws { | |
| 153 | let deps = try #require(try sections().last) | |
| 154 | #expect(deps.rows.isEmpty) | |
| 155 | #expect(deps.empty == "No check has failed") | |
| 156 | } | |
| 157 | ||
| 158 | /// Everyone else's dashboard omits both blocks. | |
| 159 | @Test func aNonAdminDashboardHasNoQueues() throws { | |
| 160 | let json = #"{"protocol_version":1,"data":{"unread":3},"exit_code":0}"# | |
| 161 | let dashboard = try GitbayClient.decoder().decode(Envelope.self, from: Data(json.utf8)).data | |
| 162 | #expect(dashboard.queues == nil) | |
| 163 | #expect(dashboard.server == nil) | |
| 164 | } | |
| 165 | ||
| 166 | @Test func anUnparseableTimestampIsShownAsIs() { | |
| 167 | #expect(AdminQueues.relative("soon") == "soon") | |
| 168 | } | |
| 169 | } | |
| 170 | ``` | |
| 171 | ||
| 172 | - [ ] **Step 3: Run the unit target to verify it fails** | |
| 173 | ||
| 174 | Expected: build FAILS with `cannot find type 'AdminDashboard' in scope` and `cannot find type 'QueueSection' in scope`. | |
| 175 | ||
| 176 | - [ ] **Step 4: Write the models** | |
| 177 | ||
| 178 | Create `gitbay/Admin/AdminModels.swift`: | |
| 179 | ||
| 180 | ```swift | |
| 181 | import Foundation | |
| 182 | ||
| 183 | /// The admin-only half of `dashboard`: the server build and every | |
| 184 | /// background worker's backlog. The web's `/admin` page is this read. | |
| 185 | /// Both are absent for anyone who is not an instance admin. | |
| 186 | nonisolated struct AdminDashboard: Decodable, Sendable, Hashable { | |
| 187 | let queues: AdminQueues? | |
| 188 | let server: Server? | |
| 189 | ||
| 190 | nonisolated struct Server: Decodable, Sendable, Hashable { | |
| 191 | let commit: String? | |
| 192 | } | |
| 193 | } | |
| 194 | ||
| 195 | /// `dashboard`'s `queues` block. | |
| 196 | nonisolated struct AdminQueues: Decodable, Sendable, Hashable { | |
| 197 | let webhooks: Queue | |
| 198 | let mail: Queue | |
| 199 | let push: Queue | |
| 200 | let mirrors: Queue | |
| 201 | let builds: Queue | |
| 202 | let deps: Queue | |
| 203 | ||
| 204 | /// Each queue reports a different subset of these counts; the | |
| 205 | /// absent ones stay nil. | |
| 206 | nonisolated struct Queue: Decodable, Sendable, Hashable { | |
| 207 | let pending: Int? | |
| 208 | let retrying: Int? | |
| 209 | let failed: Int? | |
| 210 | let running: Int? | |
| 211 | let dirty: Int? | |
| 212 | let errors: Int? | |
| 213 | let oldestPending: String? | |
| 214 | let items: [Row]? | |
| 215 | ||
| 216 | enum CodingKeys: String, CodingKey { | |
| 217 | case pending, retrying, failed, running, dirty, errors, items | |
| 218 | case oldestPending = "oldest_pending" | |
| 219 | } | |
| 220 | ||
| 221 | var rows: [Row] { items ?? [] } | |
| 222 | } | |
| 223 | ||
| 224 | /// A row of any queue; each kind fills its own fields. Timestamps | |
| 225 | /// stay strings: a pending build's `started_at` is `""`, which no | |
| 226 | /// date decoder accepts. | |
| 227 | nonisolated struct Row: Decodable, Sendable, Hashable { | |
| 228 | let repo: String? | |
| 229 | let url: String? | |
| 230 | let recipient: String? | |
| 231 | let subject: String? | |
| 232 | let title: String? | |
| 233 | let deviceID: Int64? | |
| 234 | let direction: String? | |
| 235 | let number: Int64? | |
| 236 | let job: String? | |
| 237 | let status: String? | |
| 238 | let attempts: Int64? | |
| 239 | let lastStatus: Int64? | |
| 240 | let lastError: String? | |
| 241 | let failedAt: String? | |
| 242 | let lastSync: String? | |
| 243 | let lastCheck: String? | |
| 244 | let startedAt: String? | |
| 245 | let createdAt: String? | |
| 246 | ||
| 247 | enum CodingKeys: String, CodingKey { | |
| 248 | case repo, url, recipient, subject, title, direction, number, job, status, attempts | |
| 249 | case deviceID = "device_id" | |
| 250 | case lastStatus = "last_status" | |
| 251 | case lastError = "last_error" | |
| 252 | case failedAt = "failed_at" | |
| 253 | case lastSync = "last_sync" | |
| 254 | case lastCheck = "last_check" | |
| 255 | case startedAt = "started_at" | |
| 256 | case createdAt = "created_at" | |
| 257 | } | |
| 258 | } | |
| 259 | ||
| 260 | /// The web's six sections, in its order and its words | |
| 261 | /// (`internal/web/templates/admin.html`). `when` renders a timestamp; | |
| 262 | /// tests pass a fixed one. | |
| 263 | func sections(when: (String) -> String = AdminQueues.relative) -> [QueueSection] { | |
| 264 | func oldest(_ queue: Queue) -> String { | |
| 265 | guard let at = queue.oldestPending, !at.isEmpty else { return "" } | |
| 266 | return " · oldest pending \(when(at))" | |
| 267 | } | |
| 268 | func n(_ value: Int?) -> Int { value ?? 0 } | |
| 269 | func attempts(_ row: Row) -> String? { row.attempts.map { "\($0) attempts" } } | |
| 270 | func state(_ row: Row, failed: String) -> String { | |
| 271 | guard let at = row.failedAt, !at.isEmpty else { return "retrying" } | |
| 272 | return "\(failed) \(when(at))" | |
| 273 | } | |
| 274 | func lines(_ queue: Queue, _ make: (Row) -> (String, [String?])) -> [QueueLine] { | |
| 275 | queue.rows.enumerated().map { index, row in | |
| 276 | let (title, parts) = make(row) | |
| 277 | let detail = parts.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ") | |
| 278 | return QueueLine(id: index, title: title, detail: detail) | |
| 279 | } | |
| 280 | } | |
| 281 | ||
| 282 | return [ | |
| 283 | QueueSection( | |
| 284 | id: "webhooks", heading: "Webhook deliveries", count: n(webhooks.pending), | |
| 285 | summary: "\(n(webhooks.pending)) pending · \(n(webhooks.retrying)) retrying · \(n(webhooks.failed)) dead-lettered" + oldest(webhooks), | |
| 286 | rows: lines(webhooks) { row in | |
| 287 | let error = [row.lastStatus.map(String.init), row.lastError] | |
| 288 | .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ") | |
| 289 | return (row.repo ?? "", [row.url, attempts(row), state(row, failed: "dead-lettered"), error]) | |
| 290 | }, | |
| 291 | empty: "Nothing retrying or dead-lettered"), | |
| 292 | QueueSection( | |
| 293 | id: "mail", heading: "Mail", count: n(mail.pending), | |
| 294 | summary: "\(n(mail.pending)) pending · \(n(mail.retrying)) retrying · \(n(mail.failed)) failed" + oldest(mail), | |
| 295 | rows: lines(mail) { row in | |
| 296 | (row.recipient ?? "", [row.subject, attempts(row), state(row, failed: "failed"), row.lastError]) | |
| 297 | }, | |
| 298 | empty: "Nothing retrying or failed"), | |
| 299 | // A push row names the device id, never the token. | |
| 300 | QueueSection( | |
| 301 | id: "push", heading: "Push", count: n(push.pending), | |
| 302 | summary: "\(n(push.pending)) pending · \(n(push.retrying)) retrying · \(n(push.failed)) failed" + oldest(push), | |
| 303 | rows: lines(push) { row in | |
| 304 | ("device \(row.deviceID.map(String.init) ?? "?")", | |
| 305 | [row.title, attempts(row), state(row, failed: "failed"), row.lastError]) | |
| 306 | }, | |
| 307 | empty: "Nothing retrying or failed"), | |
| 308 | QueueSection( | |
| 309 | id: "mirrors", heading: "Mirrors", count: n(mirrors.errors), | |
| 310 | summary: "\(n(mirrors.dirty)) waiting for a sync · \(n(mirrors.errors)) with a failed last sync", | |
| 311 | rows: lines(mirrors) { row in | |
| 312 | let sync = row.lastSync.flatMap { $0.isEmpty ? nil : "last sync \(when($0))" } ?? "never synced" | |
| 313 | return (row.repo ?? "", [row.direction, row.url, sync, row.lastError]) | |
| 314 | }, | |
| 315 | empty: "Every mirror's last sync succeeded"), | |
| 316 | QueueSection( | |
| 317 | id: "builds", heading: "Builds", count: n(builds.pending), | |
| 318 | summary: "\(n(builds.pending)) pending · \(n(builds.running)) running" + oldest(builds), | |
| 319 | rows: lines(builds) { row in | |
| 320 | let started = row.startedAt.flatMap { $0.isEmpty ? nil : $0 } | |
| 321 | let since = (started ?? row.createdAt).map { "since \(when($0))" } | |
| 322 | return ("\(row.repo ?? "") #\(row.number.map(String.init) ?? "?")", [row.job, row.status, since]) | |
| 323 | }, | |
| 324 | empty: "No build running or pending"), | |
| 325 | QueueSection( | |
| 326 | id: "deps", heading: "Dependency checks", count: n(deps.errors), | |
| 327 | summary: nil, | |
| 328 | rows: lines(deps) { row in | |
| 329 | let check = row.lastCheck.flatMap { $0.isEmpty ? nil : "last check \(when($0))" } ?? "never checked" | |
| 330 | return (row.repo ?? "", [check, row.lastError]) | |
| 331 | }, | |
| 332 | empty: "No check has failed"), | |
| 333 | ] | |
| 334 | } | |
| 335 | ||
| 336 | /// "5 minutes ago", or the timestamp itself when it does not parse. | |
| 337 | static func relative(_ timestamp: String) -> String { | |
| 338 | let fractional = ISO8601DateFormatter() | |
| 339 | fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] | |
| 340 | guard let date = fractional.date(from: timestamp) ?? ISO8601DateFormatter().date(from: timestamp) | |
| 341 | else { return timestamp } | |
| 342 | return date.formatted(.relative(presentation: .named)) | |
| 343 | } | |
| 344 | } | |
| 345 | ||
| 346 | /// One section of the admin screen: a heading with its count, the counts | |
| 347 | /// line, the rows, and what an empty queue says. | |
| 348 | nonisolated struct QueueSection: Sendable, Hashable, Identifiable { | |
| 349 | let id: String | |
| 350 | let heading: String | |
| 351 | let count: Int | |
| 352 | let summary: String? | |
| 353 | let rows: [QueueLine] | |
| 354 | let empty: String | |
| 355 | } | |
| 356 | ||
| 357 | nonisolated struct QueueLine: Sendable, Hashable, Identifiable { | |
| 358 | let id: Int | |
| 359 | let title: String | |
| 360 | let detail: String | |
| 361 | } | |
| 362 | ``` | |
| 363 | ||
| 364 | - [ ] **Step 5: Run the unit target to verify it passes** | |
| 365 | ||
| 366 | Expected: `Passed <BASE + 6> 0`. If a detail string differs, fix the model, not the test: the strings are the web template's. | |
| 367 | ||
| 368 | - [ ] **Step 6: Commit** | |
| 369 | ||
| 370 | ```bash | |
| 371 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Admin/AdminModels.swift gitbayTests/AdminTests.swift && git commit -m "admin: decode dashboard's queues into the web's six sections" | |
| 372 | ``` | |
| 373 | ||
| 374 | --- | |
| 375 | ||
| 376 | ### Task 2: Accounts, their actions, and who is an admin | |
| 377 | ||
| 378 | **Files:** | |
| 379 | - Modify: `gitbay/Admin/AdminModels.swift` (append `AdminUser`, `AdminAction`, `AdminUserFilter`) | |
| 380 | - Create: `gitbay/Admin/AdminViewModels.swift` | |
| 381 | - Modify: `gitbayTests/AdminTests.swift` (append suites) | |
| 382 | ||
| 383 | **Interfaces:** | |
| 384 | - Consumes: `PagedListModel<Element>(client:argv:emptyMessage:pageSize:)`, `GitbayClient.read(_:as:)`, `GitbayClient.run(_:)`, `LoadState`. | |
| 385 | - Produces: | |
| 386 | - `AdminUser: Decodable, Identifiable` — `username: String`, `state: String`, `admin: Bool`, `createdAt: Date`, `lastSeen: Date?`; `roleAction: AdminAction`, `canChangeRole: Bool`, `accessAction: AdminAction`. | |
| 387 | - `AdminAction: String` — `.promote`, `.demote`, `.disable`, `.enable`; `needsTypedName: Bool`, `title: String`, `argv(_ username: String) -> [String]`, `message(for username: String) -> String`. | |
| 388 | - `AdminUserFilter: String, CaseIterable, Identifiable` — `.all`, `.active`, `.pending`, `.disabled`, `.admin`; `label: String`, `flags() -> [String]`. | |
| 389 | - `enum AdminCheck` — `static func isAdmin(_ client: GitbayClient) async -> Bool`. | |
| 390 | - `AdminOverviewViewModel(client:)` — `state: LoadState<AdminOverviewViewModel.Overview>`, `load() async`; `Overview` has `commit: String?`, `sections: [QueueSection]`. | |
| 391 | - `AdminUsersViewModel(client:)` — `list: PagedListModel<AdminUser>`, `filter: AdminUserFilter`, `actionError: String?`, `working: Bool`, `load() async`, `perform(_ action: AdminAction, on username: String) async`. | |
| 392 | ||
| 393 | - [ ] **Step 1: Write the failing tests** | |
| 394 | ||
| 395 | Append to `gitbayTests/AdminTests.swift`: | |
| 396 | ||
| 397 | ```swift | |
| 398 | private func user(_ name: String, state: String, admin: Bool = false) throws -> AdminUser { | |
| 399 | let json = """ | |
| 400 | {"username":"\(name)","state":"\(state)","admin":\(admin),\ | |
| 401 | "created_at":"2026-08-28T21:55:00.752Z"} | |
| 402 | """ | |
| 403 | return try GitbayClient.decoder().decode(AdminUser.self, from: Data(json.utf8)) | |
| 404 | } | |
| 405 | ||
| 406 | struct AdminUserActionTests { | |
| 407 | ||
| 408 | @Test func anAdminIsDemotedAndCanBeDisabled() throws { | |
| 409 | let u = try user("cmc", state: "active", admin: true) | |
| 410 | #expect(u.roleAction == .demote) | |
| 411 | #expect(u.canChangeRole) | |
| 412 | #expect(u.accessAction == .disable) | |
| 413 | } | |
| 414 | ||
| 415 | @Test func anActiveAccountCanBePromoted() throws { | |
| 416 | let u = try user("blotter-ci", state: "active") | |
| 417 | #expect(u.roleAction == .promote) | |
| 418 | #expect(u.canChangeRole) | |
| 419 | } | |
| 420 | ||
| 421 | /// The web draws Promote on every non-admin row but disables it for | |
| 422 | /// an account that is not active. | |
| 423 | @Test func onlyAnActiveAccountCanBePromoted() throws { | |
| 424 | #expect(try user("new", state: "pending").canChangeRole == false) | |
| 425 | #expect(try user("gone", state: "disabled").canChangeRole == false) | |
| 426 | } | |
| 427 | ||
| 428 | @Test func aDisabledAccountIsEnabledAndAnyOtherDisabled() throws { | |
| 429 | #expect(try user("gone", state: "disabled").accessAction == .enable) | |
| 430 | #expect(try user("new", state: "pending").accessAction == .disable) | |
| 431 | } | |
| 432 | ||
| 433 | @Test func demoteAndDisableAskForTheTypedName() { | |
| 434 | #expect(AdminAction.demote.needsTypedName) | |
| 435 | #expect(AdminAction.disable.needsTypedName) | |
| 436 | #expect(!AdminAction.promote.needsTypedName) | |
| 437 | #expect(!AdminAction.enable.needsTypedName) | |
| 438 | } | |
| 439 | ||
| 440 | @Test func eachActionIsItsAdminUserCommand() { | |
| 441 | #expect(AdminAction.promote.argv("x") == ["admin", "user", "promote", "x"]) | |
| 442 | #expect(AdminAction.demote.argv("x") == ["admin", "user", "demote", "x"]) | |
| 443 | #expect(AdminAction.disable.argv("x") == ["admin", "user", "disable", "x"]) | |
| 444 | #expect(AdminAction.enable.argv("x") == ["admin", "user", "enable", "x"]) | |
| 445 | } | |
| 446 | ||
| 447 | @Test func theFiltersAreTheWebsFive() { | |
| 448 | #expect(AdminUserFilter.allCases.map(\.label) == ["All", "Active", "Pending", "Disabled", "Admins"]) | |
| 449 | #expect(AdminUserFilter.all.flags().isEmpty) | |
| 450 | #expect(AdminUserFilter.admin.flags() == ["--state", "admin"]) | |
| 451 | } | |
| 452 | } | |
| 453 | ||
| 454 | @MainActor | |
| 455 | struct AdminCheckTests { | |
| 456 | ||
| 457 | @Test func whoamisAdminFlagDecides() async throws { | |
| 458 | let (client, stub) = try makeClient() | |
| 459 | stub.enqueue(.init(status: 200, json: | |
| 460 | #"{"protocol_version":1,"data":{"username":"cmc","admin":true,"key_scope":"full"}}"#)) | |
| 461 | #expect(await AdminCheck.isAdmin(client)) | |
| 462 | #expect(stub.seen.first?.url.query() == "argv=whoami") | |
| 463 | } | |
| 464 | ||
| 465 | @Test func aNonAdminIsNot() async throws { | |
| 466 | let (client, stub) = try makeClient() | |
| 467 | stub.enqueue(.init(status: 200, json: | |
| 468 | #"{"protocol_version":1,"data":{"username":"ios-smoke","admin":false}}"#)) | |
| 469 | #expect(await AdminCheck.isAdmin(client) == false) | |
| 470 | } | |
| 471 | ||
| 472 | /// The menu just leaves the entry out when the read fails. | |
| 473 | @Test func aFailedReadIsNotAnAdmin() async throws { | |
| 474 | let (client, stub) = try makeClient() | |
| 475 | stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) | |
| 476 | #expect(await AdminCheck.isAdmin(client) == false) | |
| 477 | } | |
| 478 | } | |
| 479 | ||
| 480 | @MainActor | |
| 481 | struct AdminOverviewViewModelTests { | |
| 482 | ||
| 483 | @Test func readsDashboardIntoSections() async throws { | |
| 484 | let (client, stub) = try makeClient() | |
| 485 | stub.enqueue(.init(status: 200, json: adminDashboardJSON)) | |
| 486 | let model = AdminOverviewViewModel(client: client) | |
| 487 | ||
| 488 | await model.load() | |
| 489 | ||
| 490 | let overview = try #require(model.state.value) | |
| 491 | #expect(overview.commit == "db7503f06f11") | |
| 492 | #expect(overview.sections.count == 6) | |
| 493 | #expect(stub.seen.first?.url.query() == "argv=dashboard") | |
| 494 | } | |
| 495 | ||
| 496 | @Test func aDashboardWithoutQueuesIsAnEmptyState() async throws { | |
| 497 | let (client, stub) = try makeClient() | |
| 498 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"unread":0},"exit_code":0}"#)) | |
| 499 | let model = AdminOverviewViewModel(client: client) | |
| 500 | ||
| 501 | await model.load() | |
| 502 | ||
| 503 | guard case .empty(let message) = model.state else { | |
| 504 | Issue.record("expected .empty, got \(model.state)") | |
| 505 | return | |
| 506 | } | |
| 507 | #expect(message == "Only instance admins can see the worker queues.") | |
| 508 | } | |
| 509 | } | |
| 510 | ||
| 511 | private let userPageJSON = """ | |
| 512 | {"protocol_version":1,"data":{"items":[\ | |
| 513 | {"username":"apple-review","state":"active","admin":false,"created_at":"2026-08-28T21:55:00.752Z",\ | |
| 514 | "last_seen":"2026-09-21T07:05:26.201Z"},\ | |
| 515 | {"username":"bhargavkk","state":"pending","admin":false,"created_at":"2026-09-11T01:50:04.063Z"}],\ | |
| 516 | "next":"YWRtaW4tdXNlcjpiaGFyZ2F2a2s"},"exit_code":0} | |
| 517 | """ | |
| 518 | ||
| 519 | @MainActor | |
| 520 | struct AdminUsersViewModelTests { | |
| 521 | ||
| 522 | @Test func listsEveryAccountByDefault() async throws { | |
| 523 | let (client, stub) = try makeClient() | |
| 524 | stub.enqueue(.init(status: 200, json: userPageJSON)) | |
| 525 | let model = AdminUsersViewModel(client: client) | |
| 526 | ||
| 527 | await model.load() | |
| 528 | ||
| 529 | let users = try #require(model.list.state.value) | |
| 530 | #expect(users.map(\.username) == ["apple-review", "bhargavkk"]) | |
| 531 | #expect(users[0].lastSeen != nil) | |
| 532 | #expect(users[1].lastSeen == nil) | |
| 533 | #expect(model.list.hasMore) | |
| 534 | #expect(stub.seen.first?.url.query() == "argv=admin&argv=user&argv=list&argv=--limit&argv=50") | |
| 535 | } | |
| 536 | ||
| 537 | @Test func aFilterReloadsWithItsState() async throws { | |
| 538 | let (client, stub) = try makeClient() | |
| 539 | stub.enqueue(.init(status: 200, json: userPageJSON)) | |
| 540 | let model = AdminUsersViewModel(client: client) | |
| 541 | await model.load() | |
| 542 | ||
| 543 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) | |
| 544 | model.filter = .disabled | |
| 545 | await until { | |
| 546 | if case .empty = model.list.state { return true } | |
| 547 | return false | |
| 548 | } | |
| 549 | ||
| 550 | #expect(stub.seen.last?.url.query() | |
| 551 | == "argv=admin&argv=user&argv=list&argv=--state&argv=disabled&argv=--limit&argv=50") | |
| 552 | guard case .empty(let message) = model.list.state else { return } | |
| 553 | #expect(message == "No account matches") | |
| 554 | } | |
| 555 | ||
| 556 | @Test func anActionSendsItsCommandAndReloads() async throws { | |
| 557 | let (client, stub) = try makeClient() | |
| 558 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "cmd")) | |
| 559 | stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list")) | |
| 560 | let model = AdminUsersViewModel(client: client) | |
| 561 | ||
| 562 | await model.perform(.disable, on: "bhargavkk") | |
| 563 | ||
| 564 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 565 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | |
| 566 | #expect(body["argv"] as? [String] == ["admin", "user", "disable", "bhargavkk"]) | |
| 567 | #expect(model.actionError == nil) | |
| 568 | #expect(model.list.state.value?.count == 2) | |
| 569 | } | |
| 570 | ||
| 571 | @Test func aRefusalIsShownVerbatim() async throws { | |
| 572 | let (client, stub) = try makeClient() | |
| 573 | stub.enqueue(.init(status: 200, json: | |
| 574 | #"{"protocol_version":1,"error":"cmc is the last admin","exit_code":4}"#, match: "cmd")) | |
| 575 | let model = AdminUsersViewModel(client: client) | |
| 576 | ||
| 577 | await model.perform(.demote, on: "cmc") | |
| 578 | ||
| 579 | #expect(model.actionError == "cmc is the last admin") | |
| 580 | } | |
| 581 | } | |
| 582 | ``` | |
| 583 | ||
| 584 | - [ ] **Step 2: Run the unit target to verify it fails** | |
| 585 | ||
| 586 | Expected: build FAILS with `cannot find type 'AdminUser' in scope`, `cannot find 'AdminCheck' in scope`, `cannot find 'AdminOverviewViewModel' in scope`, `cannot find 'AdminUsersViewModel' in scope`. | |
| 587 | ||
| 588 | - [ ] **Step 3: Append the account types to the models** | |
| 589 | ||
| 590 | Append to `gitbay/Admin/AdminModels.swift`: | |
| 591 | ||
| 592 | ```swift | |
| 593 | /// One row of `admin user list`. | |
| 594 | nonisolated struct AdminUser: Decodable, Sendable, Hashable, Identifiable { | |
| 595 | let username: String | |
| 596 | /// active, pending or disabled. | |
| 597 | let state: String | |
| 598 | let admin: Bool | |
| 599 | let createdAt: Date | |
| 600 | /// Absent for an account that has never signed in. | |
| 601 | let lastSeen: Date? | |
| 602 | ||
| 603 | enum CodingKeys: String, CodingKey { | |
| 604 | case username, state, admin | |
| 605 | case createdAt = "created_at" | |
| 606 | case lastSeen = "last_seen" | |
| 607 | } | |
| 608 | ||
| 609 | var id: String { username } | |
| 610 | ||
| 611 | /// The role button the web draws: Demote for an admin, Promote for | |
| 612 | /// anyone else. | |
| 613 | var roleAction: AdminAction { admin ? .demote : .promote } | |
| 614 | /// Only an active account can be promoted; the web draws the button | |
| 615 | /// disabled for the rest. | |
| 616 | var canChangeRole: Bool { admin || state == "active" } | |
| 617 | var accessAction: AdminAction { state == "disabled" ? .enable : .disable } | |
| 618 | } | |
| 619 | ||
| 620 | nonisolated enum AdminAction: String, Sendable, Hashable { | |
| 621 | case promote, demote, disable, enable | |
| 622 | ||
| 623 | /// Demote and disable take something away from the account, and the | |
| 624 | /// web asks for the username typed before either. | |
| 625 | var needsTypedName: Bool { self == .demote || self == .disable } | |
| 626 | ||
| 627 | var title: String { rawValue.capitalized } | |
| 628 | ||
| 629 | func argv(_ username: String) -> [String] { ["admin", "user", rawValue, username] } | |
| 630 | ||
| 631 | func message(for username: String) -> String { | |
| 632 | switch self { | |
| 633 | case .promote: "\(username) becomes an instance admin." | |
| 634 | case .demote: "\(username) stops being an instance admin. Type the username to confirm." | |
| 635 | case .disable: "SSH, web sessions and API tokens for \(username) are refused until the account is enabled. Type the username to confirm." | |
| 636 | case .enable: "\(username) can sign in again." | |
| 637 | } | |
| 638 | } | |
| 639 | } | |
| 640 | ||
| 641 | /// The web's five filters over `admin user list`. | |
| 642 | nonisolated enum AdminUserFilter: String, CaseIterable, Identifiable, Sendable { | |
| 643 | case all, active, pending, disabled, admin | |
| 644 | ||
| 645 | var id: String { rawValue } | |
| 646 | var label: String { self == .admin ? "Admins" : rawValue.capitalized } | |
| 647 | ||
| 648 | func flags() -> [String] { self == .all ? [] : ["--state", rawValue] } | |
| 649 | } | |
| 650 | ``` | |
| 651 | ||
| 652 | - [ ] **Step 4: Write the view models** | |
| 653 | ||
| 654 | Create `gitbay/Admin/AdminViewModels.swift`: | |
| 655 | ||
| 656 | ```swift | |
| 657 | import Foundation | |
| 658 | import Observation | |
| 659 | ||
| 660 | /// Whether the signed-in account is an instance admin, from `whoami`. A | |
| 661 | /// failed read counts as not: the account menu then leaves Admin out. | |
| 662 | enum AdminCheck { | |
| 663 | ||
| 664 | nonisolated private struct Who: Decodable, Sendable { | |
| 665 | let admin: Bool? | |
| 666 | } | |
| 667 | ||
| 668 | static func isAdmin(_ client: GitbayClient) async -> Bool { | |
| 669 | ((try? await client.read(["whoami"], as: Who.self))?.admin) ?? false | |
| 670 | } | |
| 671 | } | |
| 672 | ||
| 673 | /// The admin screen: `dashboard`'s server build and worker queues, the | |
| 674 | /// read the web's /admin page makes. | |
| 675 | @Observable | |
| 676 | @MainActor | |
| 677 | final class AdminOverviewViewModel { | |
| 678 | ||
| 679 | nonisolated struct Overview: Sendable, Hashable { | |
| 680 | let commit: String? | |
| 681 | let sections: [QueueSection] | |
| 682 | } | |
| 683 | ||
| 684 | private(set) var state: LoadState<Overview> = .loading | |
| 685 | ||
| 686 | private let client: GitbayClient | |
| 687 | ||
| 688 | init(client: GitbayClient) { | |
| 689 | self.client = client | |
| 690 | } | |
| 691 | ||
| 692 | func load() async { | |
| 693 | do { | |
| 694 | let dashboard = try await client.read(["dashboard"], as: AdminDashboard.self) | |
| 695 | guard let queues = dashboard.queues else { | |
| 696 | state = .empty("Only instance admins can see the worker queues.") | |
| 697 | return | |
| 698 | } | |
| 699 | state = .loaded(Overview(commit: dashboard.server?.commit, sections: queues.sections())) | |
| 700 | } catch { | |
| 701 | state = .from(error) | |
| 702 | } | |
| 703 | } | |
| 704 | } | |
| 705 | ||
| 706 | /// `admin user list`, narrowed by the web's five filters, and the four | |
| 707 | /// writes /admin/users makes per row. | |
| 708 | @Observable | |
| 709 | @MainActor | |
| 710 | final class AdminUsersViewModel { | |
| 711 | ||
| 712 | let list: PagedListModel<AdminUser> | |
| 713 | var filter = AdminUserFilter.all { | |
| 714 | didSet { | |
| 715 | guard filter != oldValue else { return } | |
| 716 | list.argv = ["admin", "user", "list"] + filter.flags() | |
| 717 | reloadTask?.cancel() | |
| 718 | reloadTask = Task { await list.reload() } | |
| 719 | } | |
| 720 | } | |
| 721 | private var reloadTask: Task<Void, Never>? | |
| 722 | private(set) var actionError: String? | |
| 723 | private(set) var working = false | |
| 724 | ||
| 725 | private let client: GitbayClient | |
| 726 | ||
| 727 | init(client: GitbayClient) { | |
| 728 | self.client = client | |
| 729 | list = PagedListModel( | |
| 730 | client: client, | |
| 731 | argv: ["admin", "user", "list"], | |
| 732 | emptyMessage: "No account matches" | |
| 733 | ) | |
| 734 | } | |
| 735 | ||
| 736 | func load() async { | |
| 737 | reloadTask?.cancel() | |
| 738 | await list.reload() | |
| 739 | } | |
| 740 | ||
| 741 | func perform(_ action: AdminAction, on username: String) async { | |
| 742 | working = true | |
| 743 | actionError = nil | |
| 744 | defer { working = false } | |
| 745 | do { | |
| 746 | try await client.run(action.argv(username)) | |
| 747 | await list.reload() | |
| 748 | } catch let error as GitbayError { | |
| 749 | actionError = error.userFacingMessage | |
| 750 | } catch { | |
| 751 | actionError = GitbayError.transport(error).userFacingMessage | |
| 752 | } | |
| 753 | } | |
| 754 | } | |
| 755 | ``` | |
| 756 | ||
| 757 | - [ ] **Step 5: Run the unit target to verify it passes** | |
| 758 | ||
| 759 | Expected: `Passed <BASE + 22> 0` — Task 1's 6, plus 7 action, 3 check, 2 overview and 4 accounts tests. | |
| 760 | ||
| 761 | If `aFailedReadIsNotAnAdmin` retries: the client honours `Retry-After` only on 429, so a 500 fails once. | |
| 762 | ||
| 763 | - [ ] **Step 6: Commit** | |
| 764 | ||
| 765 | ```bash | |
| 766 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Admin/AdminModels.swift gitbay/Admin/AdminViewModels.swift gitbayTests/AdminTests.swift && git commit -m "admin: accounts, their actions, and who is an admin | |
| 767 | ||
| 768 | admin user list with the web's five filters; promote, demote, disable | |
| 769 | and enable with the web's rules for which a row offers; whoami's admin | |
| 770 | flag for the account menu." | |
| 771 | ``` | |
| 772 | ||
| 773 | --- | |
| 774 | ||
| 775 | ### Task 3: The screens and the menu entry | |
| 776 | ||
| 777 | **Files:** | |
| 778 | - Create: `gitbay/Views/Admin/AdminView.swift` | |
| 779 | - Create: `gitbay/Views/Admin/AdminUsersView.swift` | |
| 780 | - Modify: `gitbay/Views/Repos/RepoRoute.swift:26` (two cases after `case notifications`) | |
| 781 | - Modify: `gitbay/ContentView.swift:204-205` (two destinations after `.notifications`) | |
| 782 | - Modify: `gitbay/Views/Repos/RepoListView.swift:250-291` (`AccountMenu`) | |
| 783 | ||
| 784 | **Interfaces:** | |
| 785 | - Consumes: everything Task 2 produces; `LoadStateOverlay(state:)`, `PageFooter(list:)`, `GBChip`, `GBNotice`, `SessionStore.client`, `SessionStore.current`. | |
| 786 | - Produces: `RepoRoute.admin`, `RepoRoute.adminUsers`; accessibility identifiers `account-menu-admin`, `admin-accounts`, `admin-user-filter`, `admin-user-menu-<username>`. | |
| 787 | ||
| 788 | - [ ] **Step 1: Add the routes** | |
| 789 | ||
| 790 | In `gitbay/Views/Repos/RepoRoute.swift`, after ` case notifications` add: | |
| 791 | ||
| 792 | ```swift | |
| 793 | case admin | |
| 794 | case adminUsers | |
| 795 | ``` | |
| 796 | ||
| 797 | In `gitbay/ContentView.swift`, in `destination(_ route: RepoRoute, client:)`, after | |
| 798 | ||
| 799 | ```swift | |
| 800 | case .notifications: | |
| 801 | NotificationsView(client: client) | |
| 802 | ``` | |
| 803 | ||
| 804 | add | |
| 805 | ||
| 806 | ```swift | |
| 807 | case .admin: | |
| 808 | AdminView(client: client) | |
| 809 | case .adminUsers: | |
| 810 | AdminUsersView(client: client) | |
| 811 | ``` | |
| 812 | ||
| 813 | - [ ] **Step 2: The admin screen** | |
| 814 | ||
| 815 | Create `gitbay/Views/Admin/AdminView.swift`: | |
| 816 | ||
| 817 | ```swift | |
| 818 | import SwiftUI | |
| 819 | ||
| 820 | /// The web's /admin page: the server build, a way to the accounts, and | |
| 821 | /// every worker queue's backlog in the web's order and words. | |
| 822 | struct AdminView: View { | |
| 823 | ||
| 824 | @State private var model: AdminOverviewViewModel | |
| 825 | ||
| 826 | init(client: GitbayClient) { | |
| 827 | _model = State(initialValue: AdminOverviewViewModel(client: client)) | |
| 828 | } | |
| 829 | ||
| 830 | var body: some View { | |
| 831 | List { | |
| 832 | if let overview = model.state.value { | |
| 833 | Section { | |
| 834 | NavigationLink(value: RepoRoute.adminUsers) { | |
| 835 | Label("Accounts", systemImage: "person.2") | |
| 836 | } | |
| 837 | .accessibilityIdentifier("admin-accounts") | |
| 838 | } footer: { | |
| 839 | if let commit = overview.commit { | |
| 840 | Text("Server build \(commit).") | |
| 841 | } | |
| 842 | } | |
| 843 | ForEach(overview.sections) { section in | |
| 844 | Section { | |
| 845 | if section.rows.isEmpty { | |
| 846 | Text(section.empty) | |
| 847 | .font(.gbSans(.subheadline)) | |
| 848 | .foregroundStyle(.secondary) | |
| 849 | } else { | |
| 850 | ForEach(section.rows) { line in | |
| 851 | VStack(alignment: .leading, spacing: 2) { | |
| 852 | Text(line.title) | |
| 853 | .font(.gbSans(.subheadline).weight(.medium)) | |
| 854 | Text(line.detail) | |
| 855 | .font(.gbSans(.caption)) | |
| 856 | .foregroundStyle(.secondary) | |
| 857 | } | |
| 858 | } | |
| 859 | } | |
| 860 | } header: { | |
| 861 | Text("\(section.heading) \(section.count)") | |
| 862 | } footer: { | |
| 863 | if let summary = section.summary { | |
| 864 | Text(summary) | |
| 865 | } | |
| 866 | } | |
| 867 | } | |
| 868 | } | |
| 869 | } | |
| 870 | .overlay { LoadStateOverlay(state: model.state) } | |
| 871 | .navigationTitle("Admin") | |
| 872 | .navigationBarTitleDisplayMode(.inline) | |
| 873 | .task { await model.load() } | |
| 874 | .refreshable { await model.load() } | |
| 875 | } | |
| 876 | } | |
| 877 | ``` | |
| 878 | ||
| 879 | - [ ] **Step 3: The accounts screen** | |
| 880 | ||
| 881 | Create `gitbay/Views/Admin/AdminUsersView.swift`: | |
| 882 | ||
| 883 | ```swift | |
| 884 | import SwiftUI | |
| 885 | ||
| 886 | /// The web's /admin/users: every account, filtered by state, with the | |
| 887 | /// role and access action each row allows. Demote and disable ask for | |
| 888 | /// the username typed, as the web does. | |
| 889 | struct AdminUsersView: View { | |
| 890 | ||
| 891 | @State private var model: AdminUsersViewModel | |
| 892 | @State private var pending: Pending? | |
| 893 | @State private var typed = "" | |
| 894 | ||
| 895 | nonisolated struct Pending: Hashable, Sendable { | |
| 896 | let action: AdminAction | |
| 897 | let username: String | |
| 898 | } | |
| 899 | ||
| 900 | init(client: GitbayClient) { | |
| 901 | _model = State(initialValue: AdminUsersViewModel(client: client)) | |
| 902 | } | |
| 903 | ||
| 904 | var body: some View { | |
| 905 | List { | |
| 906 | Picker("State", selection: Bindable(model).filter) { | |
| 907 | ForEach(AdminUserFilter.allCases) { filter in | |
| 908 | Text(filter.label).tag(filter) | |
| 909 | } | |
| 910 | } | |
| 911 | .pickerStyle(.segmented) | |
| 912 | .listRowBackground(Color.clear) | |
| 913 | .listRowInsets(EdgeInsets()) | |
| 914 | .accessibilityIdentifier("admin-user-filter") | |
| 915 | ||
| 916 | if let error = model.actionError { | |
| 917 | Section { | |
| 918 | GBNotice(error, .gbWarn) | |
| 919 | } | |
| 920 | } | |
| 921 | ForEach(model.list.state.value ?? []) { user in | |
| 922 | row(user) | |
| 923 | } | |
| 924 | PageFooter(list: model.list) | |
| 925 | } | |
| 926 | .overlay { LoadStateOverlay(state: model.list.state) } | |
| 927 | .navigationTitle("Accounts") | |
| 928 | .navigationBarTitleDisplayMode(.inline) | |
| 929 | .task { await model.load() } | |
| 930 | .refreshable { await model.load() } | |
| 931 | .alert( | |
| 932 | pending.map { "\($0.action.title) \($0.username)?" } ?? "", | |
| 933 | isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }), | |
| 934 | presenting: pending | |
| 935 | ) { pending in | |
| 936 | if pending.action.needsTypedName { | |
| 937 | TextField(pending.username, text: $typed) | |
| 938 | .autocorrectionDisabled() | |
| 939 | .textInputAutocapitalization(.never) | |
| 940 | } | |
| 941 | Button(pending.action.title, role: pending.action.needsTypedName ? .destructive : nil) { | |
| 942 | Task { await model.perform(pending.action, on: pending.username) } | |
| 943 | } | |
| 944 | .disabled(pending.action.needsTypedName && typed != pending.username) | |
| 945 | Button("Cancel", role: .cancel) {} | |
| 946 | } message: { pending in | |
| 947 | Text(pending.action.message(for: pending.username)) | |
| 948 | } | |
| 949 | } | |
| 950 | ||
| 951 | private func row(_ user: AdminUser) -> some View { | |
| 952 | HStack { | |
| 953 | VStack(alignment: .leading, spacing: 3) { | |
| 954 | HStack(spacing: 6) { | |
| 955 | Text(user.username) | |
| 956 | .font(.gbSans(.subheadline).weight(.medium)) | |
| 957 | if user.admin { | |
| 958 | GBChip("admin", .gbAccent) | |
| 959 | } | |
| 960 | GBChip(user.state, stateColor(user.state)) | |
| 961 | } | |
| 962 | Text(seen(user)) | |
| 963 | .font(.gbSans(.caption)) | |
| 964 | .foregroundStyle(.secondary) | |
| 965 | } | |
| 966 | Spacer() | |
| 967 | Menu { | |
| 968 | Button(user.roleAction.title) { ask(user.roleAction, user) } | |
| 969 | .disabled(!user.canChangeRole || model.working) | |
| 970 | Button(user.accessAction.title, | |
| 971 | role: user.accessAction == .disable ? .destructive : nil) { | |
| 972 | ask(user.accessAction, user) | |
| 973 | } | |
| 974 | .disabled(model.working) | |
| 975 | } label: { | |
| 976 | Image(systemName: "ellipsis.circle") | |
| 977 | } | |
| 978 | .accessibilityIdentifier("admin-user-menu-\(user.username)") | |
| 979 | } | |
| 980 | } | |
| 981 | ||
| 982 | private func ask(_ action: AdminAction, _ user: AdminUser) { | |
| 983 | typed = "" | |
| 984 | pending = Pending(action: action, username: user.username) | |
| 985 | } | |
| 986 | ||
| 987 | /// The web's chip classes: open for active, closed for disabled, | |
| 988 | /// neutral otherwise. | |
| 989 | private func stateColor(_ state: String) -> Color { | |
| 990 | switch state { | |
| 991 | case "active": .gbOK | |
| 992 | case "disabled": .gbBad | |
| 993 | default: .secondary | |
| 994 | } | |
| 995 | } | |
| 996 | ||
| 997 | private func seen(_ user: AdminUser) -> String { | |
| 998 | let created = "joined \(user.createdAt.formatted(date: .abbreviated, time: .omitted))" | |
| 999 | let last = user.lastSeen.map { "last seen \($0.formatted(.relative(presentation: .named)))" } | |
| 1000 | ?? "never seen" | |
| 1001 | return "\(created) · \(last)" | |
| 1002 | } | |
| 1003 | } | |
| 1004 | ``` | |
| 1005 | ||
| 1006 | - [ ] **Step 4: Offer it from the account menu, to admins only** | |
| 1007 | ||
| 1008 | In `gitbay/Views/Repos/RepoListView.swift`, in `AccountMenu`: | |
| 1009 | ||
| 1010 | Add after `@Environment(SessionStore.self) private var session`: | |
| 1011 | ||
| 1012 | ```swift | |
| 1013 | /// Read from `whoami` when the menu appears and on an account switch. | |
| 1014 | /// The menu has no model; this is the one read that reaches it. | |
| 1015 | @State private var isAdmin = false | |
| 1016 | ``` | |
| 1017 | ||
| 1018 | Inside the `Section(current.label) { … }`, after the Snippets `NavigationLink`, add: | |
| 1019 | ||
| 1020 | ```swift | |
| 1021 | if isAdmin { | |
| 1022 | NavigationLink(value: RepoRoute.admin) { | |
| 1023 | Label("Admin", systemImage: "gearshape.2") | |
| 1024 | } | |
| 1025 | .accessibilityIdentifier("account-menu-admin") | |
| 1026 | } | |
| 1027 | ``` | |
| 1028 | ||
| 1029 | Replace the menu's label | |
| 1030 | ||
| 1031 | ```swift | |
| 1032 | } label: { | |
| 1033 | Image(systemName: "person.crop.circle") | |
| 1034 | } | |
| 1035 | ``` | |
| 1036 | ||
| 1037 | with | |
| 1038 | ||
| 1039 | ```swift | |
| 1040 | } label: { | |
| 1041 | Image(systemName: "person.crop.circle") | |
| 1042 | // ToolbarContent takes no .task; the label is a view. | |
| 1043 | .task(id: session.current?.id) { | |
| 1044 | isAdmin = false | |
| 1045 | if let client = session.client { | |
| 1046 | isAdmin = await AdminCheck.isAdmin(client) | |
| 1047 | } | |
| 1048 | } | |
| 1049 | } | |
| 1050 | ``` | |
| 1051 | ||
| 1052 | - [ ] **Step 5: Build and run the unit target** | |
| 1053 | ||
| 1054 | Expected: same count as after Task 2, 0 failed. | |
| 1055 | ||
| 1056 | - [ ] **Step 6: Commit** | |
| 1057 | ||
| 1058 | ```bash | |
| 1059 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Views/Admin gitbay/Views/Repos/RepoRoute.swift gitbay/ContentView.swift gitbay/Views/Repos/RepoListView.swift && git commit -m "admin: the queues and accounts screens, offered to admins | |
| 1060 | ||
| 1061 | The account menu shows Admin when whoami says the account is an | |
| 1062 | instance admin." | |
| 1063 | ``` | |
| 1064 | ||
| 1065 | --- | |
| 1066 | ||
| 1067 | ### Task 4: Live and manual checks, then the MR | |
| 1068 | ||
| 1069 | **Files:** | |
| 1070 | - Modify: `gitbayUITests/LiveSmokeUITests.swift` (`testProfileAndNavigationFlows`, the account menu step) | |
| 1071 | ||
| 1072 | **Interfaces:** | |
| 1073 | - Consumes: `account-menu`, `account-menu-admin` (Task 3). | |
| 1074 | ||
| 1075 | - [ ] **Step 1: Assert a non-admin is not offered Admin** | |
| 1076 | ||
| 1077 | In `testProfileAndNavigationFlows()`, replace | |
| 1078 | ||
| 1079 | ```swift | |
| 1080 | XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5), | |
| 1081 | "keys not in the account menu") | |
| 1082 | app.tap() // dismiss the menu; the profile is already on screen | |
| 1083 | ``` | |
| 1084 | ||
| 1085 | with | |
| 1086 | ||
| 1087 | ```swift | |
| 1088 | XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5), | |
| 1089 | "keys not in the account menu") | |
| 1090 | // ios-smoke is not an instance admin. | |
| 1091 | XCTAssertFalse(app.descendants(matching: .any) | |
| 1092 | .matching(identifier: "account-menu-admin").firstMatch.exists, | |
| 1093 | "a non-admin is offered Admin") | |
| 1094 | app.tap() // dismiss the menu; the profile is already on screen | |
| 1095 | ``` | |
| 1096 | ||
| 1097 | - [ ] **Step 2: Build the UI test target and run the test (needs a token from the user)** | |
| 1098 | ||
| 1099 | ```bash | |
| 1100 | cd /Users/cmc/git/krz/gitbay-ios && TEST_RUNNER_GITBAY_UITEST_LIVE=1 TEST_RUNNER_GITBAY_UITEST_TOKEN="$TOKEN" \ | |
| 1101 | xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' \ | |
| 1102 | -only-testing:gitbayUITests/LiveSmokeUITests/testProfileAndNavigationFlows \ | |
| 1103 | -resultBundlePath /tmp/gb-live.xcresult test 2>&1 | grep -E "error:|passed|failed|TEST (SUCCEEDED|FAILED)" | tail -10 | |
| 1104 | ``` | |
| 1105 | ||
| 1106 | Expected: `passed`. The token is for `ios-smoke`, minted by the user as in earlier plans. | |
| 1107 | ||
| 1108 | - [ ] **Step 3: Read-only look as an admin (the user's call)** | |
| 1109 | ||
| 1110 | Ask the user whether they want to sign the simulator in as `cmc` to look at the screens. If yes, they paste their own token into the app; never type it yourself. Then, on the iPhone 17 Pro simulator: open My Profile → account menu → Admin, confirm six sections and the server build match `https://gitbay.org/admin`; open Accounts, switch through the five filters, scroll to the end of All. Open one row's menu and check its two items against the web's row for the same account. **Do not confirm any action.** Screenshot Admin and Accounts in light and dark, and afterwards remind the user to sign the simulator back out or reset its keychain (`xcrun simctl keychain 0A03DF15-0F45-4726-8080-DBFE919F46B6 reset`) before the next live run. | |
| 1111 | ||
| 1112 | - [ ] **Step 4: Design greps** | |
| 1113 | ||
| 1114 | ```bash | |
| 1115 | cd /Users/cmc/git/krz/gitbay-ios && grep -rnE '\.(green|red|blue|orange|yellow|purple)\b' gitbay/ --include=*.swift | grep -v gb; \ | |
| 1116 | grep -rn '\.font(\.' gitbay/Views/Admin | grep -vE 'gbSans|gbMono'; \ | |
| 1117 | grep -rn 'cornerRadius:' gitbay/Views/Admin | grep -v 'cornerRadius: 2)' | |
| 1118 | ``` | |
| 1119 | ||
| 1120 | Expected: no output. | |
| 1121 | ||
| 1122 | - [ ] **Step 5: Both runtimes and a Release device build** | |
| 1123 | ||
| 1124 | Run the unit target on iPhone 17 Pro (26.5), then: | |
| 1125 | ||
| 1126 | ```bash | |
| 1127 | cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | |
| 1128 | -destination 'id=9F8E5D84-0A15-4C64-8A45-994667A73817' -only-testing:gitbayTests test 2>&1 | grep -E "TEST (SUCCEEDED|FAILED)" | |
| 1129 | xcodebuild build -scheme gitbay -configuration Release -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO 2>&1 \ | |
| 1130 | | grep -E "error:|While running pass|BUILD (SUCCEEDED|FAILED)" | tail -5 | |
| 1131 | ``` | |
| 1132 | ||
| 1133 | Expected: `** TEST SUCCEEDED **` and `** BUILD SUCCEEDED **`. On iOS 18, `formatted(.relative(presentation: .named))` and the `switch` expressions in `AdminAction` are both available. | |
| 1134 | ||
| 1135 | - [ ] **Step 6: Commit, push, open the MR** | |
| 1136 | ||
| 1137 | ```bash | |
| 1138 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbayUITests/LiveSmokeUITests.swift && git commit -m "live suite: a non-admin is not offered Admin" && \ | |
| 1139 | git push -u origin admin && gitbay mr create --source admin --target main \ | |
| 1140 | --title "Admin: worker queues and accounts, as the web has them" --file - <<'EOF' | |
| 1141 | The web's /admin and /admin/users, for an instance admin. | |
| 1142 | ||
| 1143 | - Admin: the server build and dashboard's six worker queues, with the | |
| 1144 | web's headings, counts lines, rows and empty sentences. | |
| 1145 | - Accounts: admin user list with the web's five filters, paged. Each | |
| 1146 | row offers the web's role and access actions; demote and disable ask | |
| 1147 | for the username typed. | |
| 1148 | - The account menu shows Admin when whoami says the account is an | |
| 1149 | admin. | |
| 1150 | ||
| 1151 | No account show, invite, runners, repository administration, audit | |
| 1152 | log or statistics: the web has none of them either. | |
| 1153 | ||
| 1154 | Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md | |
| 1155 | EOF | |
| 1156 | ``` | |
| 1157 | ||
| 1158 | --- | |
| 1159 | ||
| 1160 | ### Task 5: Bump to 1.6.0 (15) | |
| 1161 | ||
| 1162 | Starts only after the user has merged the MR from Task 4. | |
| 1163 | ||
| 1164 | **Files:** | |
| 1165 | - Modify: `gitbay.xcodeproj/project.pbxproj` (the four version lines) | |
| 1166 | ||
| 1167 | - [ ] **Step 1: Branch, bump, verify, open the MR** | |
| 1168 | ||
| 1169 | ```bash | |
| 1170 | cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c bump-1.6.0 && \ | |
| 1171 | sed -i '' -e 's/MARKETING_VERSION = 1.5.0;/MARKETING_VERSION = 1.6.0;/' \ | |
| 1172 | -e 's/CURRENT_PROJECT_VERSION = 14;/CURRENT_PROJECT_VERSION = 15;/' gitbay.xcodeproj/project.pbxproj && \ | |
| 1173 | git diff --stat && plutil -lint gitbay.xcodeproj/project.pbxproj | |
| 1174 | ``` | |
| 1175 | ||
| 1176 | Expected: `1 file changed, 4 insertions(+), 4 deletions(-)` and `OK`. Run the unit target, then: | |
| 1177 | ||
| 1178 | ```bash | |
| 1179 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay.xcodeproj/project.pbxproj && git commit -m "Bump to 1.6.0 (15)" && \ | |
| 1180 | git push -u origin bump-1.6.0 && gitbay mr create --source bump-1.6.0 --target main --title "Bump to 1.6.0 (15)" --body "Admin: worker queues and accounts." | |
| 1181 | ``` | |
| 1182 | ||
| 1183 | Tagging `v1.6.0`, archiving and uploading are the user's. | |
| 1184 | ||
| 1185 | - [ ] **Step 2: Flip the Parity rows upstream** | |
| 1186 | ||
| 1187 | Once 1.6.0 is released, in `~/git/krz/gitbay` on a new branch `parity-ios-1.6.0`, set the iOS column to `yes` for `account list, filter by state`, `promote, demote`, `disable, enable` and `worker queues` under Administration. Commit, push, and open the MR titled "Parity: iOS 1.6.0 rows". | |