gitbay/Views/Admin/AdminUsersView.swift

v1.6.1
gitbay-ios/gitbay/Views/Admin/AdminUsersView.swift history · blame · raw

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}