| @@ -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 | } |