gitbay/Views/Admin/AdminUsersView.swift

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

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}