Commit 77544ccaf2
Verified · cmc
Layout: unified · split
gitbay/ContentView.swift +4
| @@ -203,6 +203,10 @@ private struct RouteDestinations: ViewModifier { | |||
| 203 | SignInView() | 203 | SignInView() |
| 204 | case .notifications: | 204 | case .notifications: |
| 205 | NotificationsView(client: client) | 205 | NotificationsView(client: client) |
| 206 | case .admin: | ||
| 207 | AdminView(client: client) | ||
| 208 | case .adminUsers: | ||
| 209 | AdminUsersView(client: client) | ||
| 206 | } | 210 | } |
| 207 | } | 211 | } |
| 208 | 212 | ||
gitbay/Views/Admin/AdminUsersView.swift added +120
| @@ -0,0 +1,120 @@ | |||
| 1 | import 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. | ||
| 6 | struct 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 @@ | |||
| 1 | import 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. | ||
| 5 | struct 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 { | |||
| 254 | 254 | ||
| 255 | @Environment(SessionStore.self) private var session | 255 | @Environment(SessionStore.self) private var session |
| 256 | 256 | ||
| 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 | |||
| 257 | var body: some ToolbarContent { | 261 | var body: some ToolbarContent { |
| 258 | ToolbarItem(placement: .topBarTrailing) { | 262 | ToolbarItem(placement: .topBarTrailing) { |
| 259 | Menu { | 263 | Menu { |
| @@ -265,6 +269,12 @@ struct AccountMenu: ToolbarContent { | |||
| 265 | NavigationLink(value: SnippetRoute.list(owner: nil)) { | 269 | NavigationLink(value: SnippetRoute.list(owner: nil)) { |
| 266 | Label("Snippets", systemImage: "scissors") | 270 | Label("Snippets", systemImage: "scissors") |
| 267 | } | 271 | } |
| 272 | if isAdmin { | ||
| 273 | NavigationLink(value: RepoRoute.admin) { | ||
| 274 | Label("Admin", systemImage: "gearshape.2") | ||
| 275 | } | ||
| 276 | .accessibilityIdentifier("account-menu-admin") | ||
| 277 | } | ||
| 268 | } | 278 | } |
| 269 | } | 279 | } |
| 270 | if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty { | 280 | if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty { |
| @@ -286,6 +296,13 @@ struct AccountMenu: ToolbarContent { | |||
| 286 | } | 296 | } |
| 287 | } label: { | 297 | } label: { |
| 288 | Image(systemName: "person.crop.circle") | 298 | 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 | } | ||
| 289 | } | 306 | } |
| 290 | .accessibilityIdentifier("account-menu") | 307 | .accessibilityIdentifier("account-menu") |
| 291 | } | 308 | } |
gitbay/Views/Repos/RepoRoute.swift +2
| @@ -24,4 +24,6 @@ nonisolated enum RepoRoute: Hashable { | |||
| 24 | case account | 24 | case account |
| 25 | case addAccount | 25 | case addAccount |
| 26 | case notifications | 26 | case notifications |
| 27 | case admin | ||
| 28 | case adminUsers | ||
| 27 | } | 29 | } |