# 1.6.0 Admin Implementation Plan > **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. **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. **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. **Tech Stack:** Swift 6 language mode with default `MainActor` isolation, SwiftUI, Swift Testing, XCUITest. iOS 18.0 minimum, iOS 26.5 SDK. **Spec:** `docs/superpowers/specs/2026-09-22-parity-followup-design.md` (section "1.6.0: admin") ## Global Constraints - **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. - **Commits are signed.** `commit.gpgsign` is on; do not pass `--no-gpg-sign`. - **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. - **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. - **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. - **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. - **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. - **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":"…"}}`. - **Design tokens only.** `.gbSans`/`.gbMono` fonts, `GBChip` for chips, `GBNotice` for errors, `gb` palette colours. Run the design greps in Task 4. - Comments explain *why*, in plain direct English, at the density of the surrounding code. No before/after commentary. - **Run the whole unit target, and read the count.** ```bash cd /Users/cmc/git/krz/gitbay-ios && rm -rf /tmp/gb.xcresult && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' -only-testing:gitbayTests \ -resultBundlePath /tmp/gb.xcresult test 2>&1 | grep -E "error:|failed|TEST (SUCCEEDED|FAILED)" | tail -20 xcrun xcresulttool get test-results summary --path /tmp/gb.xcresult --format json \ | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['result'], d['totalTestCount'], d['failedTests'])" ``` "Run the unit target" below means these two commands. Record the count before Task 1 as `BASE`. --- ### Task 1: Queue sections, as the web lays them out **Files:** - Create: `gitbay/Admin/AdminModels.swift` - Create: `gitbayTests/AdminTests.swift` **Interfaces:** - Produces: - `AdminDashboard: Decodable` with `queues: AdminQueues?`, `server: AdminDashboard.Server?` (`commit: String?`). - `AdminQueues: Decodable` with `webhooks`, `mail`, `push`, `mirrors`, `builds`, `deps: AdminQueues.Queue`, and `func sections(when: (String) -> String = AdminQueues.relative) -> [QueueSection]`. - `static func AdminQueues.relative(_ timestamp: String) -> String`. - `QueueSection` (`id: String`, `heading: String`, `count: Int`, `summary: String?`, `rows: [QueueLine]`, `empty: String`); `QueueLine` (`id: Int`, `title: String`, `detail: String`). - [ ] **Step 1: Branch** ```bash cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c admin ``` Run the unit target and record the count as `BASE`. - [ ] **Step 2: Write the failing tests** Create `gitbayTests/AdminTests.swift`: ```swift import Foundation import Testing @testable import gitbay private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } /// `dashboard` as an admin sees it, trimmed to the admin blocks, with /// one row in every queue that lists rows. private let adminDashboardJSON = """ {"protocol_version":1,"data":{"unread":0,\ "server":{"commit":"db7503f06f11"},\ "queues":{\ "webhooks":{"pending":2,"retrying":1,"failed":1,"oldest_pending":"2026-09-22T04:00:00Z","items":[\ {"id":9,"repo":"krz/gitbay","url":"https://ci.example.com/hook","attempts":5,\ "last_status":502,"last_error":"bad gateway","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T03:00:00Z"}]},\ "mail":{"pending":0,"retrying":1,"failed":0,"items":[\ {"id":4,"recipient":"a@example.com","subject":"krz/gitbay#12","attempts":2,\ "last_error":"dial tcp: timeout","created_at":"2026-09-22T03:00:00Z"}]},\ "mirrors":{"dirty":3,"errors":1,"items":[\ {"id":1,"repo":"krz/solar","direction":"push","url":"https://github.com/x/solar",\ "last_error":"auth failed"}]},\ "builds":{"pending":1,"running":1,"items":[\ {"repo":"krz/gitbay","number":1480,"job":"test","status":"running",\ "created_at":"2026-09-22T04:40:00Z","started_at":"2026-09-22T04:41:00Z"},\ {"repo":"krz/gitbay","number":1481,"job":"build","status":"pending",\ "created_at":"2026-09-22T04:42:00Z","started_at":""}]},\ "deps":{"errors":0,"items":[]},\ "push":{"pending":0,"retrying":0,"failed":1,"items":[\ {"id":7,"device_id":3,"title":"krz/gitbay!450 merged","attempts":3,\ "last_error":"BadDeviceToken","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T04:00:00Z"}]}\ }},"exit_code":0} """ nonisolated private struct Envelope: Decodable { let data: AdminDashboard } private func sections() throws -> [QueueSection] { let dashboard = try GitbayClient.decoder() .decode(Envelope.self, from: Data(adminDashboardJSON.utf8)).data let queues = try #require(dashboard.queues) // A fixed stand-in for relative time keeps the text deterministic. return queues.sections(when: { "at \($0)" }) } struct AdminQueueSectionTests { @Test func sixSectionsInTheWebsOrder() throws { let all = try sections() #expect(all.map(\.heading) == [ "Webhook deliveries", "Mail", "Push", "Mirrors", "Builds", "Dependency checks", ]) #expect(all.map(\.count) == [2, 0, 0, 1, 1, 0]) } @Test func countsLinesUseTheWebsWords() throws { let all = try sections() #expect(all[0].summary == "2 pending · 1 retrying · 1 dead-lettered · oldest pending at 2026-09-22T04:00:00Z") #expect(all[1].summary == "0 pending · 1 retrying · 0 failed") #expect(all[3].summary == "3 waiting for a sync · 1 with a failed last sync") #expect(all[4].summary == "1 pending · 1 running") #expect(all[5].summary == nil) } @Test func rowsSayWhatTheWebsTableColumnsSay() throws { let all = try sections() #expect(all[0].rows.map(\.title) == ["krz/gitbay"]) #expect(all[0].rows[0].detail == "https://ci.example.com/hook · 5 attempts · dead-lettered at 2026-09-22T05:00:00Z · 502 bad gateway") #expect(all[1].rows[0].title == "a@example.com") #expect(all[1].rows[0].detail == "krz/gitbay#12 · 2 attempts · retrying · dial tcp: timeout") #expect(all[2].rows[0].title == "device 3") #expect(all[2].rows[0].detail == "krz/gitbay!450 merged · 3 attempts · failed at 2026-09-22T05:00:00Z · BadDeviceToken") #expect(all[3].rows[0].detail == "push · https://github.com/x/solar · never synced · auth failed") // A pending build has started_at "" and reports since it was queued. #expect(all[4].rows.map(\.title) == ["krz/gitbay #1480", "krz/gitbay #1481"]) #expect(all[4].rows[0].detail == "test · running · since at 2026-09-22T04:41:00Z") #expect(all[4].rows[1].detail == "build · pending · since at 2026-09-22T04:42:00Z") } @Test func anEmptyQueueSaysSoInTheWebsWords() throws { let deps = try #require(try sections().last) #expect(deps.rows.isEmpty) #expect(deps.empty == "No check has failed") } /// Everyone else's dashboard omits both blocks. @Test func aNonAdminDashboardHasNoQueues() throws { let json = #"{"protocol_version":1,"data":{"unread":3},"exit_code":0}"# let dashboard = try GitbayClient.decoder().decode(Envelope.self, from: Data(json.utf8)).data #expect(dashboard.queues == nil) #expect(dashboard.server == nil) } @Test func anUnparseableTimestampIsShownAsIs() { #expect(AdminQueues.relative("soon") == "soon") } } ``` - [ ] **Step 3: Run the unit target to verify it fails** Expected: build FAILS with `cannot find type 'AdminDashboard' in scope` and `cannot find type 'QueueSection' in scope`. - [ ] **Step 4: Write the models** Create `gitbay/Admin/AdminModels.swift`: ```swift import Foundation /// The admin-only half of `dashboard`: the server build and every /// background worker's backlog. The web's `/admin` page is this read. /// Both are absent for anyone who is not an instance admin. nonisolated struct AdminDashboard: Decodable, Sendable, Hashable { let queues: AdminQueues? let server: Server? nonisolated struct Server: Decodable, Sendable, Hashable { let commit: String? } } /// `dashboard`'s `queues` block. nonisolated struct AdminQueues: Decodable, Sendable, Hashable { let webhooks: Queue let mail: Queue let push: Queue let mirrors: Queue let builds: Queue let deps: Queue /// Each queue reports a different subset of these counts; the /// absent ones stay nil. nonisolated struct Queue: Decodable, Sendable, Hashable { let pending: Int? let retrying: Int? let failed: Int? let running: Int? let dirty: Int? let errors: Int? let oldestPending: String? let items: [Row]? enum CodingKeys: String, CodingKey { case pending, retrying, failed, running, dirty, errors, items case oldestPending = "oldest_pending" } var rows: [Row] { items ?? [] } } /// A row of any queue; each kind fills its own fields. Timestamps /// stay strings: a pending build's `started_at` is `""`, which no /// date decoder accepts. nonisolated struct Row: Decodable, Sendable, Hashable { let repo: String? let url: String? let recipient: String? let subject: String? let title: String? let deviceID: Int64? let direction: String? let number: Int64? let job: String? let status: String? let attempts: Int64? let lastStatus: Int64? let lastError: String? let failedAt: String? let lastSync: String? let lastCheck: String? let startedAt: String? let createdAt: String? enum CodingKeys: String, CodingKey { case repo, url, recipient, subject, title, direction, number, job, status, attempts case deviceID = "device_id" case lastStatus = "last_status" case lastError = "last_error" case failedAt = "failed_at" case lastSync = "last_sync" case lastCheck = "last_check" case startedAt = "started_at" case createdAt = "created_at" } } /// The web's six sections, in its order and its words /// (`internal/web/templates/admin.html`). `when` renders a timestamp; /// tests pass a fixed one. func sections(when: (String) -> String = AdminQueues.relative) -> [QueueSection] { func oldest(_ queue: Queue) -> String { guard let at = queue.oldestPending, !at.isEmpty else { return "" } return " · oldest pending \(when(at))" } func n(_ value: Int?) -> Int { value ?? 0 } func attempts(_ row: Row) -> String? { row.attempts.map { "\($0) attempts" } } func state(_ row: Row, failed: String) -> String { guard let at = row.failedAt, !at.isEmpty else { return "retrying" } return "\(failed) \(when(at))" } func lines(_ queue: Queue, _ make: (Row) -> (String, [String?])) -> [QueueLine] { queue.rows.enumerated().map { index, row in let (title, parts) = make(row) let detail = parts.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ") return QueueLine(id: index, title: title, detail: detail) } } return [ QueueSection( id: "webhooks", heading: "Webhook deliveries", count: n(webhooks.pending), summary: "\(n(webhooks.pending)) pending · \(n(webhooks.retrying)) retrying · \(n(webhooks.failed)) dead-lettered" + oldest(webhooks), rows: lines(webhooks) { row in let error = [row.lastStatus.map(String.init), row.lastError] .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ") return (row.repo ?? "", [row.url, attempts(row), state(row, failed: "dead-lettered"), error]) }, empty: "Nothing retrying or dead-lettered"), QueueSection( id: "mail", heading: "Mail", count: n(mail.pending), summary: "\(n(mail.pending)) pending · \(n(mail.retrying)) retrying · \(n(mail.failed)) failed" + oldest(mail), rows: lines(mail) { row in (row.recipient ?? "", [row.subject, attempts(row), state(row, failed: "failed"), row.lastError]) }, empty: "Nothing retrying or failed"), // A push row names the device id, never the token. QueueSection( id: "push", heading: "Push", count: n(push.pending), summary: "\(n(push.pending)) pending · \(n(push.retrying)) retrying · \(n(push.failed)) failed" + oldest(push), rows: lines(push) { row in ("device \(row.deviceID.map(String.init) ?? "?")", [row.title, attempts(row), state(row, failed: "failed"), row.lastError]) }, empty: "Nothing retrying or failed"), QueueSection( id: "mirrors", heading: "Mirrors", count: n(mirrors.errors), summary: "\(n(mirrors.dirty)) waiting for a sync · \(n(mirrors.errors)) with a failed last sync", rows: lines(mirrors) { row in let sync = row.lastSync.flatMap { $0.isEmpty ? nil : "last sync \(when($0))" } ?? "never synced" return (row.repo ?? "", [row.direction, row.url, sync, row.lastError]) }, empty: "Every mirror's last sync succeeded"), QueueSection( id: "builds", heading: "Builds", count: n(builds.pending), summary: "\(n(builds.pending)) pending · \(n(builds.running)) running" + oldest(builds), rows: lines(builds) { row in let started = row.startedAt.flatMap { $0.isEmpty ? nil : $0 } let since = (started ?? row.createdAt).map { "since \(when($0))" } return ("\(row.repo ?? "") #\(row.number.map(String.init) ?? "?")", [row.job, row.status, since]) }, empty: "No build running or pending"), QueueSection( id: "deps", heading: "Dependency checks", count: n(deps.errors), summary: nil, rows: lines(deps) { row in let check = row.lastCheck.flatMap { $0.isEmpty ? nil : "last check \(when($0))" } ?? "never checked" return (row.repo ?? "", [check, row.lastError]) }, empty: "No check has failed"), ] } /// "5 minutes ago", or the timestamp itself when it does not parse. static func relative(_ timestamp: String) -> String { let fractional = ISO8601DateFormatter() fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let date = fractional.date(from: timestamp) ?? ISO8601DateFormatter().date(from: timestamp) else { return timestamp } return date.formatted(.relative(presentation: .named)) } } /// One section of the admin screen: a heading with its count, the counts /// line, the rows, and what an empty queue says. nonisolated struct QueueSection: Sendable, Hashable, Identifiable { let id: String let heading: String let count: Int let summary: String? let rows: [QueueLine] let empty: String } nonisolated struct QueueLine: Sendable, Hashable, Identifiable { let id: Int let title: String let detail: String } ``` - [ ] **Step 5: Run the unit target to verify it passes** Expected: `Passed 0`. If a detail string differs, fix the model, not the test: the strings are the web template's. - [ ] **Step 6: Commit** ```bash 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" ``` --- ### Task 2: Accounts, their actions, and who is an admin **Files:** - Modify: `gitbay/Admin/AdminModels.swift` (append `AdminUser`, `AdminAction`, `AdminUserFilter`) - Create: `gitbay/Admin/AdminViewModels.swift` - Modify: `gitbayTests/AdminTests.swift` (append suites) **Interfaces:** - Consumes: `PagedListModel(client:argv:emptyMessage:pageSize:)`, `GitbayClient.read(_:as:)`, `GitbayClient.run(_:)`, `LoadState`. - Produces: - `AdminUser: Decodable, Identifiable` — `username: String`, `state: String`, `admin: Bool`, `createdAt: Date`, `lastSeen: Date?`; `roleAction: AdminAction`, `canChangeRole: Bool`, `accessAction: AdminAction`. - `AdminAction: String` — `.promote`, `.demote`, `.disable`, `.enable`; `needsTypedName: Bool`, `title: String`, `argv(_ username: String) -> [String]`, `message(for username: String) -> String`. - `AdminUserFilter: String, CaseIterable, Identifiable` — `.all`, `.active`, `.pending`, `.disabled`, `.admin`; `label: String`, `flags() -> [String]`. - `enum AdminCheck` — `static func isAdmin(_ client: GitbayClient) async -> Bool`. - `AdminOverviewViewModel(client:)` — `state: LoadState`, `load() async`; `Overview` has `commit: String?`, `sections: [QueueSection]`. - `AdminUsersViewModel(client:)` — `list: PagedListModel`, `filter: AdminUserFilter`, `actionError: String?`, `working: Bool`, `load() async`, `perform(_ action: AdminAction, on username: String) async`. - [ ] **Step 1: Write the failing tests** Append to `gitbayTests/AdminTests.swift`: ```swift private func user(_ name: String, state: String, admin: Bool = false) throws -> AdminUser { let json = """ {"username":"\(name)","state":"\(state)","admin":\(admin),\ "created_at":"2026-08-28T21:55:00.752Z"} """ return try GitbayClient.decoder().decode(AdminUser.self, from: Data(json.utf8)) } struct AdminUserActionTests { @Test func anAdminIsDemotedAndCanBeDisabled() throws { let u = try user("cmc", state: "active", admin: true) #expect(u.roleAction == .demote) #expect(u.canChangeRole) #expect(u.accessAction == .disable) } @Test func anActiveAccountCanBePromoted() throws { let u = try user("blotter-ci", state: "active") #expect(u.roleAction == .promote) #expect(u.canChangeRole) } /// The web draws Promote on every non-admin row but disables it for /// an account that is not active. @Test func onlyAnActiveAccountCanBePromoted() throws { #expect(try user("new", state: "pending").canChangeRole == false) #expect(try user("gone", state: "disabled").canChangeRole == false) } @Test func aDisabledAccountIsEnabledAndAnyOtherDisabled() throws { #expect(try user("gone", state: "disabled").accessAction == .enable) #expect(try user("new", state: "pending").accessAction == .disable) } @Test func demoteAndDisableAskForTheTypedName() { #expect(AdminAction.demote.needsTypedName) #expect(AdminAction.disable.needsTypedName) #expect(!AdminAction.promote.needsTypedName) #expect(!AdminAction.enable.needsTypedName) } @Test func eachActionIsItsAdminUserCommand() { #expect(AdminAction.promote.argv("x") == ["admin", "user", "promote", "x"]) #expect(AdminAction.demote.argv("x") == ["admin", "user", "demote", "x"]) #expect(AdminAction.disable.argv("x") == ["admin", "user", "disable", "x"]) #expect(AdminAction.enable.argv("x") == ["admin", "user", "enable", "x"]) } @Test func theFiltersAreTheWebsFive() { #expect(AdminUserFilter.allCases.map(\.label) == ["All", "Active", "Pending", "Disabled", "Admins"]) #expect(AdminUserFilter.all.flags().isEmpty) #expect(AdminUserFilter.admin.flags() == ["--state", "admin"]) } } @MainActor struct AdminCheckTests { @Test func whoamisAdminFlagDecides() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"username":"cmc","admin":true,"key_scope":"full"}}"#)) #expect(await AdminCheck.isAdmin(client)) #expect(stub.seen.first?.url.query() == "argv=whoami") } @Test func aNonAdminIsNot() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"username":"ios-smoke","admin":false}}"#)) #expect(await AdminCheck.isAdmin(client) == false) } /// The menu just leaves the entry out when the read fails. @Test func aFailedReadIsNotAnAdmin() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) #expect(await AdminCheck.isAdmin(client) == false) } } @MainActor struct AdminOverviewViewModelTests { @Test func readsDashboardIntoSections() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: adminDashboardJSON)) let model = AdminOverviewViewModel(client: client) await model.load() let overview = try #require(model.state.value) #expect(overview.commit == "db7503f06f11") #expect(overview.sections.count == 6) #expect(stub.seen.first?.url.query() == "argv=dashboard") } @Test func aDashboardWithoutQueuesIsAnEmptyState() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"unread":0},"exit_code":0}"#)) let model = AdminOverviewViewModel(client: client) await model.load() guard case .empty(let message) = model.state else { Issue.record("expected .empty, got \(model.state)") return } #expect(message == "Only instance admins can see the worker queues.") } } private let userPageJSON = """ {"protocol_version":1,"data":{"items":[\ {"username":"apple-review","state":"active","admin":false,"created_at":"2026-08-28T21:55:00.752Z",\ "last_seen":"2026-09-21T07:05:26.201Z"},\ {"username":"bhargavkk","state":"pending","admin":false,"created_at":"2026-09-11T01:50:04.063Z"}],\ "next":"YWRtaW4tdXNlcjpiaGFyZ2F2a2s"},"exit_code":0} """ @MainActor struct AdminUsersViewModelTests { @Test func listsEveryAccountByDefault() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: userPageJSON)) let model = AdminUsersViewModel(client: client) await model.load() let users = try #require(model.list.state.value) #expect(users.map(\.username) == ["apple-review", "bhargavkk"]) #expect(users[0].lastSeen != nil) #expect(users[1].lastSeen == nil) #expect(model.list.hasMore) #expect(stub.seen.first?.url.query() == "argv=admin&argv=user&argv=list&argv=--limit&argv=50") } @Test func aFilterReloadsWithItsState() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: userPageJSON)) let model = AdminUsersViewModel(client: client) await model.load() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) model.filter = .disabled await until { if case .empty = model.list.state { return true } return false } #expect(stub.seen.last?.url.query() == "argv=admin&argv=user&argv=list&argv=--state&argv=disabled&argv=--limit&argv=50") guard case .empty(let message) = model.list.state else { return } #expect(message == "No account matches") } @Test func anActionSendsItsCommandAndReloads() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "cmd")) stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list")) let model = AdminUsersViewModel(client: client) await model.perform(.disable, on: "bhargavkk") let write = try #require(stub.seen.first { $0.method == "POST" }) let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) #expect(body["argv"] as? [String] == ["admin", "user", "disable", "bhargavkk"]) #expect(model.actionError == nil) #expect(model.list.state.value?.count == 2) } @Test func aRefusalIsShownVerbatim() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"error":"cmc is the last admin","exit_code":4}"#, match: "cmd")) let model = AdminUsersViewModel(client: client) await model.perform(.demote, on: "cmc") #expect(model.actionError == "cmc is the last admin") } } ``` - [ ] **Step 2: Run the unit target to verify it fails** 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`. - [ ] **Step 3: Append the account types to the models** Append to `gitbay/Admin/AdminModels.swift`: ```swift /// One row of `admin user list`. nonisolated struct AdminUser: Decodable, Sendable, Hashable, Identifiable { let username: String /// active, pending or disabled. let state: String let admin: Bool let createdAt: Date /// Absent for an account that has never signed in. let lastSeen: Date? enum CodingKeys: String, CodingKey { case username, state, admin case createdAt = "created_at" case lastSeen = "last_seen" } var id: String { username } /// The role button the web draws: Demote for an admin, Promote for /// anyone else. var roleAction: AdminAction { admin ? .demote : .promote } /// Only an active account can be promoted; the web draws the button /// disabled for the rest. var canChangeRole: Bool { admin || state == "active" } var accessAction: AdminAction { state == "disabled" ? .enable : .disable } } nonisolated enum AdminAction: String, Sendable, Hashable { case promote, demote, disable, enable /// Demote and disable take something away from the account, and the /// web asks for the username typed before either. var needsTypedName: Bool { self == .demote || self == .disable } var title: String { rawValue.capitalized } func argv(_ username: String) -> [String] { ["admin", "user", rawValue, username] } func message(for username: String) -> String { switch self { case .promote: "\(username) becomes an instance admin." case .demote: "\(username) stops being an instance admin. Type the username to confirm." case .disable: "SSH, web sessions and API tokens for \(username) are refused until the account is enabled. Type the username to confirm." case .enable: "\(username) can sign in again." } } } /// The web's five filters over `admin user list`. nonisolated enum AdminUserFilter: String, CaseIterable, Identifiable, Sendable { case all, active, pending, disabled, admin var id: String { rawValue } var label: String { self == .admin ? "Admins" : rawValue.capitalized } func flags() -> [String] { self == .all ? [] : ["--state", rawValue] } } ``` - [ ] **Step 4: Write the view models** Create `gitbay/Admin/AdminViewModels.swift`: ```swift import Foundation import Observation /// Whether the signed-in account is an instance admin, from `whoami`. A /// failed read counts as not: the account menu then leaves Admin out. enum AdminCheck { nonisolated private struct Who: Decodable, Sendable { let admin: Bool? } static func isAdmin(_ client: GitbayClient) async -> Bool { ((try? await client.read(["whoami"], as: Who.self))?.admin) ?? false } } /// The admin screen: `dashboard`'s server build and worker queues, the /// read the web's /admin page makes. @Observable @MainActor final class AdminOverviewViewModel { nonisolated struct Overview: Sendable, Hashable { let commit: String? let sections: [QueueSection] } private(set) var state: LoadState = .loading private let client: GitbayClient init(client: GitbayClient) { self.client = client } func load() async { do { let dashboard = try await client.read(["dashboard"], as: AdminDashboard.self) guard let queues = dashboard.queues else { state = .empty("Only instance admins can see the worker queues.") return } state = .loaded(Overview(commit: dashboard.server?.commit, sections: queues.sections())) } catch { state = .from(error) } } } /// `admin user list`, narrowed by the web's five filters, and the four /// writes /admin/users makes per row. @Observable @MainActor final class AdminUsersViewModel { let list: PagedListModel var filter = AdminUserFilter.all { didSet { guard filter != oldValue else { return } list.argv = ["admin", "user", "list"] + filter.flags() reloadTask?.cancel() reloadTask = Task { await list.reload() } } } private var reloadTask: Task? private(set) var actionError: String? private(set) var working = false private let client: GitbayClient init(client: GitbayClient) { self.client = client list = PagedListModel( client: client, argv: ["admin", "user", "list"], emptyMessage: "No account matches" ) } func load() async { reloadTask?.cancel() await list.reload() } func perform(_ action: AdminAction, on username: String) async { working = true actionError = nil defer { working = false } do { try await client.run(action.argv(username)) await list.reload() } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } } ``` - [ ] **Step 5: Run the unit target to verify it passes** Expected: `Passed 0` — Task 1's 6, plus 7 action, 3 check, 2 overview and 4 accounts tests. If `aFailedReadIsNotAnAdmin` retries: the client honours `Retry-After` only on 429, so a 500 fails once. - [ ] **Step 6: Commit** ```bash 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 admin user list with the web's five filters; promote, demote, disable and enable with the web's rules for which a row offers; whoami's admin flag for the account menu." ``` --- ### Task 3: The screens and the menu entry **Files:** - Create: `gitbay/Views/Admin/AdminView.swift` - Create: `gitbay/Views/Admin/AdminUsersView.swift` - Modify: `gitbay/Views/Repos/RepoRoute.swift:26` (two cases after `case notifications`) - Modify: `gitbay/ContentView.swift:204-205` (two destinations after `.notifications`) - Modify: `gitbay/Views/Repos/RepoListView.swift:250-291` (`AccountMenu`) **Interfaces:** - Consumes: everything Task 2 produces; `LoadStateOverlay(state:)`, `PageFooter(list:)`, `GBChip`, `GBNotice`, `SessionStore.client`, `SessionStore.current`. - Produces: `RepoRoute.admin`, `RepoRoute.adminUsers`; accessibility identifiers `account-menu-admin`, `admin-accounts`, `admin-user-filter`, `admin-user-menu-`. - [ ] **Step 1: Add the routes** In `gitbay/Views/Repos/RepoRoute.swift`, after ` case notifications` add: ```swift case admin case adminUsers ``` In `gitbay/ContentView.swift`, in `destination(_ route: RepoRoute, client:)`, after ```swift case .notifications: NotificationsView(client: client) ``` add ```swift case .admin: AdminView(client: client) case .adminUsers: AdminUsersView(client: client) ``` - [ ] **Step 2: The admin screen** Create `gitbay/Views/Admin/AdminView.swift`: ```swift import SwiftUI /// The web's /admin page: the server build, a way to the accounts, and /// every worker queue's backlog in the web's order and words. struct AdminView: View { @State private var model: AdminOverviewViewModel init(client: GitbayClient) { _model = State(initialValue: AdminOverviewViewModel(client: client)) } var body: some View { List { if let overview = model.state.value { Section { NavigationLink(value: RepoRoute.adminUsers) { Label("Accounts", systemImage: "person.2") } .accessibilityIdentifier("admin-accounts") } footer: { if let commit = overview.commit { Text("Server build \(commit).") } } ForEach(overview.sections) { section in Section { if section.rows.isEmpty { Text(section.empty) .font(.gbSans(.subheadline)) .foregroundStyle(.secondary) } else { ForEach(section.rows) { line in VStack(alignment: .leading, spacing: 2) { Text(line.title) .font(.gbSans(.subheadline).weight(.medium)) Text(line.detail) .font(.gbSans(.caption)) .foregroundStyle(.secondary) } } } } header: { Text("\(section.heading) \(section.count)") } footer: { if let summary = section.summary { Text(summary) } } } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Admin") .navigationBarTitleDisplayMode(.inline) .task { await model.load() } .refreshable { await model.load() } } } ``` - [ ] **Step 3: The accounts screen** Create `gitbay/Views/Admin/AdminUsersView.swift`: ```swift import SwiftUI /// The web's /admin/users: every account, filtered by state, with the /// role and access action each row allows. Demote and disable ask for /// the username typed, as the web does. struct AdminUsersView: View { @State private var model: AdminUsersViewModel @State private var pending: Pending? @State private var typed = "" nonisolated struct Pending: Hashable, Sendable { let action: AdminAction let username: String } init(client: GitbayClient) { _model = State(initialValue: AdminUsersViewModel(client: client)) } var body: some View { List { Picker("State", selection: Bindable(model).filter) { ForEach(AdminUserFilter.allCases) { filter in Text(filter.label).tag(filter) } } .pickerStyle(.segmented) .listRowBackground(Color.clear) .listRowInsets(EdgeInsets()) .accessibilityIdentifier("admin-user-filter") if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } ForEach(model.list.state.value ?? []) { user in row(user) } PageFooter(list: model.list) } .overlay { LoadStateOverlay(state: model.list.state) } .navigationTitle("Accounts") .navigationBarTitleDisplayMode(.inline) .task { await model.load() } .refreshable { await model.load() } .alert( pending.map { "\($0.action.title) \($0.username)?" } ?? "", isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }), presenting: pending ) { pending in if pending.action.needsTypedName { TextField(pending.username, text: $typed) .autocorrectionDisabled() .textInputAutocapitalization(.never) } Button(pending.action.title, role: pending.action.needsTypedName ? .destructive : nil) { Task { await model.perform(pending.action, on: pending.username) } } .disabled(pending.action.needsTypedName && typed != pending.username) Button("Cancel", role: .cancel) {} } message: { pending in Text(pending.action.message(for: pending.username)) } } private func row(_ user: AdminUser) -> some View { HStack { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(user.username) .font(.gbSans(.subheadline).weight(.medium)) if user.admin { GBChip("admin", .gbAccent) } GBChip(user.state, stateColor(user.state)) } Text(seen(user)) .font(.gbSans(.caption)) .foregroundStyle(.secondary) } Spacer() Menu { Button(user.roleAction.title) { ask(user.roleAction, user) } .disabled(!user.canChangeRole || model.working) Button(user.accessAction.title, role: user.accessAction == .disable ? .destructive : nil) { ask(user.accessAction, user) } .disabled(model.working) } label: { Image(systemName: "ellipsis.circle") } .accessibilityIdentifier("admin-user-menu-\(user.username)") } } private func ask(_ action: AdminAction, _ user: AdminUser) { typed = "" pending = Pending(action: action, username: user.username) } /// The web's chip classes: open for active, closed for disabled, /// neutral otherwise. private func stateColor(_ state: String) -> Color { switch state { case "active": .gbOK case "disabled": .gbBad default: .secondary } } private func seen(_ user: AdminUser) -> String { let created = "joined \(user.createdAt.formatted(date: .abbreviated, time: .omitted))" let last = user.lastSeen.map { "last seen \($0.formatted(.relative(presentation: .named)))" } ?? "never seen" return "\(created) · \(last)" } } ``` - [ ] **Step 4: Offer it from the account menu, to admins only** In `gitbay/Views/Repos/RepoListView.swift`, in `AccountMenu`: Add after `@Environment(SessionStore.self) private var session`: ```swift /// Read from `whoami` when the menu appears and on an account switch. /// The menu has no model; this is the one read that reaches it. @State private var isAdmin = false ``` Inside the `Section(current.label) { … }`, after the Snippets `NavigationLink`, add: ```swift if isAdmin { NavigationLink(value: RepoRoute.admin) { Label("Admin", systemImage: "gearshape.2") } .accessibilityIdentifier("account-menu-admin") } ``` Replace the menu's label ```swift } label: { Image(systemName: "person.crop.circle") } ``` with ```swift } label: { Image(systemName: "person.crop.circle") // ToolbarContent takes no .task; the label is a view. .task(id: session.current?.id) { isAdmin = false if let client = session.client { isAdmin = await AdminCheck.isAdmin(client) } } } ``` - [ ] **Step 5: Build and run the unit target** Expected: same count as after Task 2, 0 failed. - [ ] **Step 6: Commit** ```bash 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 The account menu shows Admin when whoami says the account is an instance admin." ``` --- ### Task 4: Live and manual checks, then the MR **Files:** - Modify: `gitbayUITests/LiveSmokeUITests.swift` (`testProfileAndNavigationFlows`, the account menu step) **Interfaces:** - Consumes: `account-menu`, `account-menu-admin` (Task 3). - [ ] **Step 1: Assert a non-admin is not offered Admin** In `testProfileAndNavigationFlows()`, replace ```swift XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5), "keys not in the account menu") app.tap() // dismiss the menu; the profile is already on screen ``` with ```swift XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5), "keys not in the account menu") // ios-smoke is not an instance admin. XCTAssertFalse(app.descendants(matching: .any) .matching(identifier: "account-menu-admin").firstMatch.exists, "a non-admin is offered Admin") app.tap() // dismiss the menu; the profile is already on screen ``` - [ ] **Step 2: Build the UI test target and run the test (needs a token from the user)** ```bash cd /Users/cmc/git/krz/gitbay-ios && TEST_RUNNER_GITBAY_UITEST_LIVE=1 TEST_RUNNER_GITBAY_UITEST_TOKEN="$TOKEN" \ xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' \ -only-testing:gitbayUITests/LiveSmokeUITests/testProfileAndNavigationFlows \ -resultBundlePath /tmp/gb-live.xcresult test 2>&1 | grep -E "error:|passed|failed|TEST (SUCCEEDED|FAILED)" | tail -10 ``` Expected: `passed`. The token is for `ios-smoke`, minted by the user as in earlier plans. - [ ] **Step 3: Read-only look as an admin (the user's call)** 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. - [ ] **Step 4: Design greps** ```bash cd /Users/cmc/git/krz/gitbay-ios && grep -rnE '\.(green|red|blue|orange|yellow|purple)\b' gitbay/ --include=*.swift | grep -v gb; \ grep -rn '\.font(\.' gitbay/Views/Admin | grep -vE 'gbSans|gbMono'; \ grep -rn 'cornerRadius:' gitbay/Views/Admin | grep -v 'cornerRadius: 2)' ``` Expected: no output. - [ ] **Step 5: Both runtimes and a Release device build** Run the unit target on iPhone 17 Pro (26.5), then: ```bash cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ -destination 'id=9F8E5D84-0A15-4C64-8A45-994667A73817' -only-testing:gitbayTests test 2>&1 | grep -E "TEST (SUCCEEDED|FAILED)" xcodebuild build -scheme gitbay -configuration Release -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO 2>&1 \ | grep -E "error:|While running pass|BUILD (SUCCEEDED|FAILED)" | tail -5 ``` Expected: `** TEST SUCCEEDED **` and `** BUILD SUCCEEDED **`. On iOS 18, `formatted(.relative(presentation: .named))` and the `switch` expressions in `AdminAction` are both available. - [ ] **Step 6: Commit, push, open the MR** ```bash cd /Users/cmc/git/krz/gitbay-ios && git add gitbayUITests/LiveSmokeUITests.swift && git commit -m "live suite: a non-admin is not offered Admin" && \ git push -u origin admin && gitbay mr create --source admin --target main \ --title "Admin: worker queues and accounts, as the web has them" --file - <<'EOF' The web's /admin and /admin/users, for an instance admin. - Admin: the server build and dashboard's six worker queues, with the web's headings, counts lines, rows and empty sentences. - Accounts: admin user list with the web's five filters, paged. Each row offers the web's role and access actions; demote and disable ask for the username typed. - The account menu shows Admin when whoami says the account is an admin. No account show, invite, runners, repository administration, audit log or statistics: the web has none of them either. Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md EOF ``` --- ### Task 5: Bump to 1.6.0 (15) Starts only after the user has merged the MR from Task 4. **Files:** - Modify: `gitbay.xcodeproj/project.pbxproj` (the four version lines) - [ ] **Step 1: Branch, bump, verify, open the MR** ```bash cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c bump-1.6.0 && \ sed -i '' -e 's/MARKETING_VERSION = 1.5.0;/MARKETING_VERSION = 1.6.0;/' \ -e 's/CURRENT_PROJECT_VERSION = 14;/CURRENT_PROJECT_VERSION = 15;/' gitbay.xcodeproj/project.pbxproj && \ git diff --stat && plutil -lint gitbay.xcodeproj/project.pbxproj ``` Expected: `1 file changed, 4 insertions(+), 4 deletions(-)` and `OK`. Run the unit target, then: ```bash cd /Users/cmc/git/krz/gitbay-ios && git add gitbay.xcodeproj/project.pbxproj && git commit -m "Bump to 1.6.0 (15)" && \ 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." ``` Tagging `v1.6.0`, archiving and uploading are the user's. - [ ] **Step 2: Flip the Parity rows upstream** 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".