gitbay/Views/Admin/AdminUsersView.swift
122 lines · 4550 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 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.loadIfNeeded() }
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
62 && typed.trimmingCharacters(in: .whitespaces) != pending.username)
63 Button("Cancel", role: .cancel) {}
64 } message: { pending in
65 Text(pending.action.message(for: pending.username))
66 }
67 }
68
69 private func row(_ user: AdminUser) -> some View {
70 HStack {
71 VStack(alignment: .leading, spacing: 3) {
72 HStack(spacing: 6) {
73 Text(user.username)
74 .font(.gbSans(.subheadline).weight(.medium))
75 if user.admin {
76 GBChip("admin", .secondary)
77 }
78 GBChip(user.state, stateColor(user.state))
79 }
80 Text(seen(user))
81 .font(.gbSans(.caption))
82 .foregroundStyle(.secondary)
83 }
84 Spacer()
85 Menu {
86 Button(user.roleAction.title) { ask(user.roleAction, user) }
87 .disabled(!user.canChangeRole || model.working)
88 Button(user.accessAction.title,
89 role: user.accessAction == .disable ? .destructive : nil) {
90 ask(user.accessAction, user)
91 }
92 .disabled(model.working)
93 } label: {
94 Image(systemName: "ellipsis.circle")
95 }
96 .accessibilityIdentifier("admin-user-menu-\(user.username)")
97 .accessibilityLabel("Actions for \(user.username)")
98 }
99 }
100
101 private func ask(_ action: AdminAction, _ user: AdminUser) {
102 typed = ""
103 pending = Pending(action: action, username: user.username)
104 }
105
106 /// The web's chip classes: open for active, closed for disabled,
107 /// neutral otherwise.
108 private func stateColor(_ state: String) -> Color {
109 switch state {
110 case "active": .gbOK
111 case "disabled": .gbBad
112 default: .secondary
113 }
114 }
115
116 private func seen(_ user: AdminUser) -> String {
117 let created = "joined \(user.createdAt.formatted(date: .abbreviated, time: .omitted))"
118 let last = user.lastSeen.map { "last seen \($0.formatted(.relative(presentation: .named)))" }
119 ?? "never seen"
120 return "\(created) · \(last)"
121 }
122}