import SwiftUI /// The web's /admin/users: every account, filtered by state, with the /// role and access action each row allows. Demote and disable ask for /// the username typed, as the web does. struct AdminUsersView: View { @State private var model: AdminUsersViewModel @State private var pending: Pending? @State private var typed = "" nonisolated struct Pending: Hashable, Sendable { let action: AdminAction let username: String } init(client: GitbayClient) { _model = State(initialValue: AdminUsersViewModel(client: client)) } var body: some View { List { Section { Picker("State", selection: Bindable(model).filter) { ForEach(AdminUserFilter.allCases) { filter in Text(filter.label).tag(filter) } } .pickerStyle(.segmented) .listRowBackground(Color.clear) .listRowInsets(EdgeInsets()) .accessibilityIdentifier("admin-user-filter") } if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } ForEach(model.list.state.value ?? []) { user in row(user) } PageFooter(list: model.list) } .overlay { LoadStateOverlay(state: model.list.state) } .navigationTitle("Accounts") .navigationBarTitleDisplayMode(.inline) .task { await model.loadIfNeeded() } .refreshable { await model.load() } .alert( pending.map { "\($0.action.title) \($0.username)?" } ?? "", isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }), presenting: pending ) { pending in if pending.action.needsTypedName { TextField(pending.username, text: $typed) .autocorrectionDisabled() .textInputAutocapitalization(.never) } Button(pending.action.title, role: pending.action.needsTypedName ? .destructive : nil) { Task { await model.perform(pending.action, on: pending.username) } } .disabled(pending.action.needsTypedName && typed.trimmingCharacters(in: .whitespaces) != pending.username) Button("Cancel", role: .cancel) {} } message: { pending in Text(pending.action.message(for: pending.username)) } } private func row(_ user: AdminUser) -> some View { HStack { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(user.username) .font(.gbSans(.subheadline).weight(.medium)) if user.admin { GBChip("admin", .secondary) } GBChip(user.state, stateColor(user.state)) } Text(seen(user)) .font(.gbSans(.caption)) .foregroundStyle(.secondary) } Spacer() Menu { Button(user.roleAction.title) { ask(user.roleAction, user) } .disabled(!user.canChangeRole || model.working) Button(user.accessAction.title, role: user.accessAction == .disable ? .destructive : nil) { ask(user.accessAction, user) } .disabled(model.working) } label: { Image(systemName: "ellipsis.circle") } .accessibilityIdentifier("admin-user-menu-\(user.username)") .accessibilityLabel("Actions for \(user.username)") } } private func ask(_ action: AdminAction, _ user: AdminUser) { typed = "" pending = Pending(action: action, username: user.username) } /// The web's chip classes: open for active, closed for disabled, /// neutral otherwise. private func stateColor(_ state: String) -> Color { switch state { case "active": .gbOK case "disabled": .gbBad default: .secondary } } private func seen(_ user: AdminUser) -> String { let created = "joined \(user.createdAt.formatted(date: .abbreviated, time: .omitted))" let last = user.lastSeen.map { "last seen \($0.formatted(.relative(presentation: .named)))" } ?? "never seen" return "\(created) ยท \(last)" } }