Commit 77544ccaf2

77544ccaf239d08c02a27ef2f6d72a963158663a

parent: e0648f4c5f

Verified · cmc

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

admin: the queues and accounts screens, offered to admins

The account menu shows Admin when whoami says the account is an
instance admin.

Layout: unified · split

gitbay/ContentView.swift +4
@@ -203,6 +203,10 @@ private struct RouteDestinations: ViewModifier {
203203 SignInView()
204204 case .notifications:
205205 NotificationsView(client: client)
206 case .admin:
207 AdminView(client: client)
208 case .adminUsers:
209 AdminUsersView(client: client)
206210 }
207211 }
208212
gitbay/Views/Admin/AdminUsersView.swift added +120
@@ -0,0 +1,120 @@
1import SwiftUI
2
3/// The web's /admin/users: every account, filtered by state, with the
4/// role and access action each row allows. Demote and disable ask for
5/// the username typed, as the web does.
6struct AdminUsersView: View {
7
8 @State private var model: AdminUsersViewModel
9 @State private var pending: Pending?
10 @State private var typed = ""
11
12 nonisolated struct Pending: Hashable, Sendable {
13 let action: AdminAction
14 let username: String
15 }
16
17 init(client: GitbayClient) {
18 _model = State(initialValue: AdminUsersViewModel(client: client))
19 }
20
21 var body: some View {
22 List {
23 Picker("State", selection: Bindable(model).filter) {
24 ForEach(AdminUserFilter.allCases) { filter in
25 Text(filter.label).tag(filter)
26 }
27 }
28 .pickerStyle(.segmented)
29 .listRowBackground(Color.clear)
30 .listRowInsets(EdgeInsets())
31 .accessibilityIdentifier("admin-user-filter")
32
33 if let error = model.actionError {
34 Section {
35 GBNotice(error, .gbWarn)
36 }
37 }
38 ForEach(model.list.state.value ?? []) { user in
39 row(user)
40 }
41 PageFooter(list: model.list)
42 }
43 .overlay { LoadStateOverlay(state: model.list.state) }
44 .navigationTitle("Accounts")
45 .navigationBarTitleDisplayMode(.inline)
46 .task { await model.load() }
47 .refreshable { await model.load() }
48 .alert(
49 pending.map { "\($0.action.title) \($0.username)?" } ?? "",
50 isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }),
51 presenting: pending
52 ) { pending in
53 if pending.action.needsTypedName {
54 TextField(pending.username, text: $typed)
55 .autocorrectionDisabled()
56 .textInputAutocapitalization(.never)
57 }
58 Button(pending.action.title, role: pending.action.needsTypedName ? .destructive : nil) {
59 Task { await model.perform(pending.action, on: pending.username) }
60 }
61 .disabled(pending.action.needsTypedName && typed != pending.username)
62 Button("Cancel", role: .cancel) {}
63 } message: { pending in
64 Text(pending.action.message(for: pending.username))
65 }
66 }
67
68 private func row(_ user: AdminUser) -> some View {
69 HStack {
70 VStack(alignment: .leading, spacing: 3) {
71 HStack(spacing: 6) {
72 Text(user.username)
73 .font(.gbSans(.subheadline).weight(.medium))
74 if user.admin {
75 GBChip("admin", .gbAccent)
76 }
77 GBChip(user.state, stateColor(user.state))
78 }
79 Text(seen(user))
80 .font(.gbSans(.caption))
81 .foregroundStyle(.secondary)
82 }
83 Spacer()
84 Menu {
85 Button(user.roleAction.title) { ask(user.roleAction, user) }
86 .disabled(!user.canChangeRole || model.working)
87 Button(user.accessAction.title,
88 role: user.accessAction == .disable ? .destructive : nil) {
89 ask(user.accessAction, user)
90 }
91 .disabled(model.working)
92 } label: {
93 Image(systemName: "ellipsis.circle")
94 }
95 .accessibilityIdentifier("admin-user-menu-\(user.username)")
96 }
97 }
98
99 private func ask(_ action: AdminAction, _ user: AdminUser) {
100 typed = ""
101 pending = Pending(action: action, username: user.username)
102 }
103
104 /// The web's chip classes: open for active, closed for disabled,
105 /// neutral otherwise.
106 private func stateColor(_ state: String) -> Color {
107 switch state {
108 case "active": .gbOK
109 case "disabled": .gbBad
110 default: .secondary
111 }
112 }
113
114 private func seen(_ user: AdminUser) -> String {
115 let created = "joined \(user.createdAt.formatted(date: .abbreviated, time: .omitted))"
116 let last = user.lastSeen.map { "last seen \($0.formatted(.relative(presentation: .named)))" }
117 ?? "never seen"
118 return "\(created) · \(last)"
119 }
120}
gitbay/Views/Admin/AdminView.swift added +59
@@ -0,0 +1,59 @@
1import SwiftUI
2
3/// The web's /admin page: the server build, a way to the accounts, and
4/// every worker queue's backlog in the web's order and words.
5struct AdminView: View {
6
7 @State private var model: AdminOverviewViewModel
8
9 init(client: GitbayClient) {
10 _model = State(initialValue: AdminOverviewViewModel(client: client))
11 }
12
13 var body: some View {
14 List {
15 if let overview = model.state.value {
16 Section {
17 NavigationLink(value: RepoRoute.adminUsers) {
18 Label("Accounts", systemImage: "person.2")
19 }
20 .accessibilityIdentifier("admin-accounts")
21 } footer: {
22 if let commit = overview.commit {
23 Text("Server build \(commit).")
24 }
25 }
26 ForEach(overview.sections) { section in
27 Section {
28 if section.rows.isEmpty {
29 Text(section.empty)
30 .font(.gbSans(.subheadline))
31 .foregroundStyle(.secondary)
32 } else {
33 ForEach(section.rows) { line in
34 VStack(alignment: .leading, spacing: 2) {
35 Text(line.title)
36 .font(.gbSans(.subheadline).weight(.medium))
37 Text(line.detail)
38 .font(.gbSans(.caption))
39 .foregroundStyle(.secondary)
40 }
41 }
42 }
43 } header: {
44 Text("\(section.heading) \(section.count)")
45 } footer: {
46 if let summary = section.summary {
47 Text(summary)
48 }
49 }
50 }
51 }
52 }
53 .overlay { LoadStateOverlay(state: model.state) }
54 .navigationTitle("Admin")
55 .navigationBarTitleDisplayMode(.inline)
56 .task { await model.load() }
57 .refreshable { await model.load() }
58 }
59}
gitbay/Views/Repos/RepoListView.swift +17
@@ -254,6 +254,10 @@ struct AccountMenu: ToolbarContent {
254254
255255 @Environment(SessionStore.self) private var session
256256
257 /// Read from `whoami` when the menu appears and on an account switch.
258 /// The menu has no model; this is the one read that reaches it.
259 @State private var isAdmin = false
260
257261 var body: some ToolbarContent {
258262 ToolbarItem(placement: .topBarTrailing) {
259263 Menu {
@@ -265,6 +269,12 @@ struct AccountMenu: ToolbarContent {
265269 NavigationLink(value: SnippetRoute.list(owner: nil)) {
266270 Label("Snippets", systemImage: "scissors")
267271 }
272 if isAdmin {
273 NavigationLink(value: RepoRoute.admin) {
274 Label("Admin", systemImage: "gearshape.2")
275 }
276 .accessibilityIdentifier("account-menu-admin")
277 }
268278 }
269279 }
270280 if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty {
@@ -286,6 +296,13 @@ struct AccountMenu: ToolbarContent {
286296 }
287297 } label: {
288298 Image(systemName: "person.crop.circle")
299 // ToolbarContent takes no .task; the label is a view.
300 .task(id: session.current?.id) {
301 isAdmin = false
302 if let client = session.client {
303 isAdmin = await AdminCheck.isAdmin(client)
304 }
305 }
289306 }
290307 .accessibilityIdentifier("account-menu")
291308 }
gitbay/Views/Repos/RepoRoute.swift +2
@@ -24,4 +24,6 @@ nonisolated enum RepoRoute: Hashable {
2424 case account
2525 case addAccount
2626 case notifications
27 case admin
28 case adminUsers
2729}