docs/superpowers/plans/2026-09-22-1.6.0-admin.md

v1.6.1
gitbay-ios/docs/superpowers/plans/2026-09-22-1.6.0-admin.md rendered · source · history · blame · raw

1187 lines · 49470 bytes

   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
  55cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c admin
  56```
  57
  58Run the unit target and record the count as `BASE`.
  59
  60- [ ] **Step 2: Write the failing tests**
  61
  62Create `gitbayTests/AdminTests.swift`:
  63
  64```swift
  65import Foundation
  66import Testing
  67@testable import gitbay
  68
  69private 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.
  81private 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
 106nonisolated private struct Envelope: Decodable { let data: AdminDashboard }
 107
 108private 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
 116struct 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
 174Expected: build FAILS with `cannot find type 'AdminDashboard' in scope` and `cannot find type 'QueueSection' in scope`.
 175
 176- [ ] **Step 4: Write the models**
 177
 178Create `gitbay/Admin/AdminModels.swift`:
 179
 180```swift
 181import 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.
 186nonisolated 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.
 196nonisolated 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.
 348nonisolated 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
 357nonisolated 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
 366Expected: `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
 371cd /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
 395Append to `gitbayTests/AdminTests.swift`:
 396
 397```swift
 398private 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
 406struct 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
 455struct 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
 481struct 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
 511private 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
 520struct 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
 586Expected: 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
 590Append to `gitbay/Admin/AdminModels.swift`:
 591
 592```swift
 593/// One row of `admin user list`.
 594nonisolated 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
 620nonisolated 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`.
 642nonisolated 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
 654Create `gitbay/Admin/AdminViewModels.swift`:
 655
 656```swift
 657import Foundation
 658import 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.
 662enum 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
 677final 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
 710final 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
 759Expected: `Passed <BASE + 22> 0` — Task 1's 6, plus 7 action, 3 check, 2 overview and 4 accounts tests.
 760
 761If `aFailedReadIsNotAnAdmin` retries: the client honours `Retry-After` only on 429, so a 500 fails once.
 762
 763- [ ] **Step 6: Commit**
 764
 765```bash
 766cd /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
 768admin user list with the web's five filters; promote, demote, disable
 769and enable with the web's rules for which a row offers; whoami's admin
 770flag 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
 790In `gitbay/Views/Repos/RepoRoute.swift`, after `    case notifications` add:
 791
 792```swift
 793    case admin
 794    case adminUsers
 795```
 796
 797In `gitbay/ContentView.swift`, in `destination(_ route: RepoRoute, client:)`, after
 798
 799```swift
 800        case .notifications:
 801            NotificationsView(client: client)
 802```
 803
 804add
 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
 815Create `gitbay/Views/Admin/AdminView.swift`:
 816
 817```swift
 818import 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.
 822struct 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
 881Create `gitbay/Views/Admin/AdminUsersView.swift`:
 882
 883```swift
 884import 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.
 889struct 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
1008In `gitbay/Views/Repos/RepoListView.swift`, in `AccountMenu`:
1009
1010Add 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
1018Inside 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
1029Replace the menu's label
1030
1031```swift
1032            } label: {
1033                Image(systemName: "person.crop.circle")
1034            }
1035```
1036
1037with
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
1054Expected: same count as after Task 2, 0 failed.
1055
1056- [ ] **Step 6: Commit**
1057
1058```bash
1059cd /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
1061The account menu shows Admin when whoami says the account is an
1062instance 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
1077In `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
1085with
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
1100cd /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
1106Expected: `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
1110Ask 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
1115cd /Users/cmc/git/krz/gitbay-ios && grep -rnE '\.(green|red|blue|orange|yellow|purple)\b' gitbay/ --include=*.swift | grep -v gb; \
1116grep -rn '\.font(\.' gitbay/Views/Admin | grep -vE 'gbSans|gbMono'; \
1117grep -rn 'cornerRadius:' gitbay/Views/Admin | grep -v 'cornerRadius: 2)'
1118```
1119
1120Expected: no output.
1121
1122- [ ] **Step 5: Both runtimes and a Release device build**
1123
1124Run the unit target on iPhone 17 Pro (26.5), then:
1125
1126```bash
1127cd /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)"
1129xcodebuild 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
1133Expected: `** 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
1138cd /Users/cmc/git/krz/gitbay-ios && git add gitbayUITests/LiveSmokeUITests.swift && git commit -m "live suite: a non-admin is not offered Admin" && \
1139git 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'
1141The 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
1151No account show, invite, runners, repository administration, audit
1152log or statistics: the web has none of them either.
1153
1154Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md
1155EOF
1156```
1157
1158---
1159
1160### Task 5: Bump to 1.6.0 (15)
1161
1162Starts 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
1170cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c bump-1.6.0 && \
1171sed -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 && \
1173git diff --stat && plutil -lint gitbay.xcodeproj/project.pbxproj
1174```
1175
1176Expected: `1 file changed, 4 insertions(+), 4 deletions(-)` and `OK`. Run the unit target, then:
1177
1178```bash
1179cd /Users/cmc/git/krz/gitbay-ios && git add gitbay.xcodeproj/project.pbxproj && git commit -m "Bump to 1.6.0 (15)" && \
1180git 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
1183Tagging `v1.6.0`, archiving and uploading are the user's.
1184
1185- [ ] **Step 2: Flip the Parity rows upstream**
1186
1187Once 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".