There was no way to change an operator password anywhere, and no way at all to help somebody who had forgotten theirs. On a server with no mail to send a link through, that left the database as the only answer. An operator can now change their own, from the dashboard and from the account row on the console's home screen. It does not ask for the current one, and that is a decision rather than an oversight: the session already authorises emptying the library, deleting every account and every board, and minting invitations, so refusing it a password change protects nothing that somebody holding it would want, while asking would cost the only recovery this thing has. What replaces the check is that the change signs every other browser and device out and is logged, so a hostile one is visible the moment it happens. The session doing the changing is kept, or the screen would log you out for fixing your password. For everybody else there is a one-time link. An operator makes one from the person's row and hands it over however they already talk to them; opening it asks for a new password and nothing else. It is shaped exactly like an invitation, and for the same reasons: long, stored only as a digest, shown once in the response that makes it, spent once, and dead after a day. Making a second kills the first, because a replaced link left alive is a loose end nobody is tracking, and redeeming one signs every device out, since somebody resetting a password is very often doing it because somebody else has the account. The front door no longer talks about whoever runs the place. It says registrations are closed and to ask for an invitation link, which is the thing the reader can actually act on. And a pass over where things are configured. Two switches both meant "can new people sign up" for two completely different sets of people and neither said which, so they are named after their populations now and each points at the other. On the console, the buttons that start a scan moved out of the group that records what has already happened and in with the sources they act on, and the group that held both people and settings is two groups, because who may use this server and how it is set up are different questions and neither should be read past to reach the other.
246 lines
7.4 KiB
Swift
246 lines
7.4 KiB
Swift
//
|
|
// 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<AdminEnvelope<[AdminUser]>>()
|
|
|
|
@State private var _busyIds: Set<Int> = []
|
|
@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()
|
|
}
|
|
}
|
|
}
|