// // AdminUsersView.swift // FTStore Admin // // Admin-only. Mirrors Settings -> Operators: approve the people waiting for // access, revoke it again, delete an account. // // "Operators", not "Users". These are the accounts that sign in and run this // server; the people who use FTStore are the Users screen on the main menu. // The two lists were named the same thing for long enough that somebody went // looking for a poster here, which is a real cost: a delete button means // something very different on each. // // The gate is the server's — `/api/users` is wrapped in `withAdmin()` — so a // non-admin reaching this screen by any means still gets a 403. Hiding the // entry point is presentation, not protection. // import SwiftUI struct AdminUsersView: View { @EnvironmentObject private var session: AdminSession @StateObject private var _users = AdminLoader>() @State private var _busyIds: Set = [] @State private var _banner: String? @State private var _pendingDelete: AdminUser? /// `nil` until `/api/settings` answers, so the switch never shows a state we /// have not actually read. @State private var _registrationOpen: Bool? @State private var _savingRegistration = false private var _pending: [AdminUser] { (_users.value?.data ?? []).filter { !$0.approved } } private var _active: [AdminUser] { (_users.value?.data ?? []).filter { $0.approved } } var body: some View { List { if let banner = _banner { Section { Text(banner).font(.footnote).foregroundStyle(.secondary) } } Section { Toggle( "Let new operators sign up", isOn: Binding( get: { _registrationOpen ?? true }, set: { _setRegistration($0) } ) ) .disabled(_registrationOpen == nil || _savingRegistration) } header: { // "Operator sign-ups", not "Registration". There are two switches // on this server that both mean "can new people sign up", for two // completely different populations, and neither used to say // which. This one is about accounts that run the server; the // other is private mode under Access, which is about accounts // that use the store. Text("Operator sign-ups") } footer: { Text("Off makes this server invite-only for operators: /admin/register stops accepting sign-ups and existing accounts are unaffected. The very first account can always be created, so a fresh install cannot lock itself out. This is not about who may use FTStore, which is Access.") } if _users.isLoading { AdminLoadingRow() } else if let error = _users.errorMessage, !_users.hasLoaded { AdminErrorRow(message: error) { Task { await _reload() } } } else { if !_pending.isEmpty { Section { ForEach(_pending) { user in _row(user) } } header: { Text("Waiting for approval") } footer: { Text("A pending account cannot sign in anywhere until it is approved.") } } Section("Accounts") { ForEach(_active) { user in _row(user) } } // The other list of people, one tap away. This screen is who may // run the server; somebody looking for a person who posted or // reviewed something has come to the wrong one, and saying so // here is cheaper than letting them work it out. Section { NavigationLink { AdminFeedbackUsersView() } label: { Label("Users of FTStore", systemImage: "person.2.circle") } } footer: { Text("Looking for somebody who posted, reviewed or requested something? That is a different list: these accounts sign in and run the server.") } } } .navigationTitle("Operators") .navigationBarTitleDisplayMode(.inline) .refreshable { await _reload() } .task { await _reload() } .confirmationDialog( "Delete this account?", isPresented: Binding( get: { _pendingDelete != nil }, set: { if !$0 { _pendingDelete = nil } } ), titleVisibility: .visible ) { Button("Delete", role: .destructive) { if let user = _pendingDelete { _delete(user) } _pendingDelete = nil } Button("Cancel", role: .cancel) { _pendingDelete = nil } } message: { Text("\(_pendingDelete?.username ?? "") loses access immediately.") } } // MARK: Row private func _row(_ user: AdminUser) -> some View { HStack(spacing: 12) { Image(systemName: user.isAdmin ? "person.badge.key.fill" : "person.crop.circle") .foregroundStyle(user.isAdmin ? AdminTheme.brand : Color.secondary) VStack(alignment: .leading, spacing: 2) { Text(user.username) .font(.subheadline.weight(.medium)) HStack(spacing: 6) { Text(user.isAdmin ? "Admin" : "User") .font(.caption2) .foregroundStyle(.secondary) if user.id == session.user?.id { Text("· you") .font(.caption2) .foregroundStyle(.tertiary) } if let created = user.createdAt { Text("· joined \(AdminFormat.relative(created))") .font(.caption2) .foregroundStyle(.tertiary) } } } Spacer(minLength: 0) if _busyIds.contains(user.id) { ProgressView() } else if user.id != session.user?.id { Menu { Button(user.approved ? "Revoke Access" : "Approve") { _setApproved(user, to: !user.approved) } Button("Delete Account", role: .destructive) { _pendingDelete = user } } label: { Image(systemName: "ellipsis.circle") .foregroundStyle(.secondary) } } } .padding(.vertical, 2) } // MARK: Actions private func _reload() async { await _users.load { try await session.get("/api/users", as: AdminEnvelope<[AdminUser]>.self) } await _reloadRegistration() } private func _reloadRegistration() async { // Absent from the settings table means "never changed", and the default // is open. A failed read leaves it `nil`, which keeps the switch disabled // rather than showing a state we did not read. guard let response = try? await session.get("/api/settings", as: AdminSettingsResponse.self) else { return } _registrationOpen = response.data.bool("registration_enabled") ?? true } private func _setRegistration(_ enabled: Bool) { let previous = _registrationOpen _registrationOpen = enabled _savingRegistration = true Task { @MainActor in do { _ = try await session.act( .put, "/api/settings", body: ["registration_enabled": AnyCodable(enabled)] ) _banner = enabled ? "Registration opened." : "Registration closed." } catch { _registrationOpen = previous _banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _savingRegistration = false } } private func _setApproved(_ user: AdminUser, to approved: Bool) { _run(user.id) { _ = try await session.act( .put, "/api/users", body: AdminRequest.UserUpdate(id: user.id, approved: approved) ) return "\(user.username) \(approved ? "approved" : "revoked")." } } private func _delete(_ user: AdminUser) { _run(user.id) { let ack = try await session.act(.delete, "/api/users", query: ["id": String(user.id)]) return ack.message ?? "\(user.username) deleted." } } private func _run(_ id: Int, _ operation: @escaping @MainActor () async throws -> String) { _busyIds.insert(id) Task { @MainActor in do { _banner = try await operation() } catch { _banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busyIds.remove(id) await _reload() } } }