gitbay/Views/Admin/AdminUsersView.swift
124 lines · 4622 bytes
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 Section {
24 Picker("State", selection: Bindable(model).filter) {
25 ForEach(AdminUserFilter.allCases) { filter in
26 Text(filter.label).tag(filter)
27 }
28 }
29 .pickerStyle(.segmented)
30 .listRowBackground(Color.clear)
31 .listRowInsets(EdgeInsets())
32 .accessibilityIdentifier("admin-user-filter")
33 }
34
35 if let error = model.actionError {
36 Section {
37 GBNotice(error, .gbWarn)
38 }
39 }
40 ForEach(model.list.state.value ?? []) { user in
41 row(user)
42 }
43 PageFooter(list: model.list)
44 }
45 .overlay { LoadStateOverlay(state: model.list.state) }
46 .navigationTitle("Accounts")
47 .navigationBarTitleDisplayMode(.inline)
48 .task { await model.loadIfNeeded() }
49 .refreshable { await model.load() }
50 .alert(
51 pending.map { "\($0.action.title) \($0.username)?" } ?? "",
52 isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }),
53 presenting: pending
54 ) { pending in
55 if pending.action.needsTypedName {
56 TextField(pending.username, text: $typed)
57 .autocorrectionDisabled()
58 .textInputAutocapitalization(.never)
59 }
60 Button(pending.action.title, role: pending.action.needsTypedName ? .destructive : nil) {
61 Task { await model.perform(pending.action, on: pending.username) }
62 }
63 .disabled(pending.action.needsTypedName
64 && typed.trimmingCharacters(in: .whitespaces) != pending.username)
65 Button("Cancel", role: .cancel) {}
66 } message: { pending in
67 Text(pending.action.message(for: pending.username))
68 }
69 }
70
71 private func row(_ user: AdminUser) -> some View {
72 HStack {
73 VStack(alignment: .leading, spacing: 3) {
74 HStack(spacing: 6) {
75 Text(user.username)
76 .font(.gbSans(.subheadline).weight(.medium))
77 if user.admin {
78 GBChip("admin", .secondary)
79 }
80 GBChip(user.state, stateColor(user.state))
81 }
82 Text(seen(user))
83 .font(.gbSans(.caption))
84 .foregroundStyle(.secondary)
85 }
86 Spacer()
87 Menu {
88 Button(user.roleAction.title) { ask(user.roleAction, user) }
89 .disabled(!user.canChangeRole || model.working)
90 Button(user.accessAction.title,
91 role: user.accessAction == .disable ? .destructive : nil) {
92 ask(user.accessAction, user)
93 }
94 .disabled(model.working)
95 } label: {
96 Image(systemName: "ellipsis.circle")
97 }
98 .accessibilityIdentifier("admin-user-menu-\(user.username)")
99 .accessibilityLabel("Actions for \(user.username)")
100 }
101 }
102
103 private func ask(_ action: AdminAction, _ user: AdminUser) {
104 typed = ""
105 pending = Pending(action: action, username: user.username)
106 }
107
108 /// The web's chip classes: open for active, closed for disabled,
109 /// neutral otherwise.
110 private func stateColor(_ state: String) -> Color {
111 switch state {
112 case "active": .gbOK
113 case "disabled": .gbBad
114 default: .secondary
115 }
116 }
117
118 private func seen(_ user: AdminUser) -> String {
119 let created = "joined \(user.createdAt.formatted(date: .abbreviated, time: .omitted))"
120 let last = user.lastSeen.map { "last seen \($0.formatted(.relative(presentation: .named)))" }
121 ?? "never seen"
122 return "\(created) · \(last)"
123 }
124}