CityTracker/client/src/pages/AdminUsersPage.tsx
Claudio Schaad 8d4448b28c Add admin backup export/import
Admins can export all cities, users, links, participants, and photos
as a zip for backups, and restore from one later. Import replaces all
existing data and clears sessions, so the admin has to log back in
afterward.
2026-07-21 20:38:37 +02:00

241 lines
7.4 KiB
TypeScript

import { useState, type ChangeEvent, type FormEvent } from 'react';
import { useUsers, useCreateUser, useUpdateUser, useResetPassword } from '../hooks/useUsers';
import { useAuth } from '../context/AuthContext';
import { ApiError } from '../api/client';
import * as backupApi from '../api/backup';
export function AdminUsersPage() {
const { data: users, isLoading } = useUsers();
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const resetPassword = useResetPassword();
const { logout } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [isAdmin, setIsAdmin] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [importing, setImporting] = useState(false);
async function handleCreate(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await createUser.mutateAsync({
username,
password,
displayName: displayName || undefined,
isAdmin,
});
setUsername('');
setPassword('');
setDisplayName('');
setIsAdmin(false);
} catch (err) {
const code =
err instanceof ApiError && err.body && typeof err.body === 'object' && 'error' in err.body
? String((err.body as { error: unknown }).error)
: null;
setError(
code === 'username_taken'
? 'That username is already taken.'
: 'Could not create user. Check the fields and try again.',
);
} finally {
setSubmitting(false);
}
}
async function handleToggleAdmin(id: number, current: boolean) {
try {
await updateUser.mutateAsync({ id, input: { isAdmin: !current } });
} catch {
window.alert(
'Could not change admin status — you may be trying to demote the last remaining admin.',
);
}
}
async function handleToggleActive(id: number, current: boolean) {
try {
await updateUser.mutateAsync({ id, input: { isActive: !current } });
} catch {
window.alert(
'Could not change this account — you may be trying to deactivate the last remaining admin.',
);
}
}
async function handleEditDisplayName(id: number, current: string | null) {
const next = window.prompt('Display name:', current ?? '');
if (next === null) return;
try {
await updateUser.mutateAsync({ id, input: { displayName: next.trim() || null } });
} catch {
window.alert('Could not update display name. Please try again.');
}
}
async function handleResetPassword(id: number) {
const newPassword = window.prompt('Enter a new password (min 8 characters):');
if (!newPassword) return;
try {
await resetPassword.mutateAsync({ id, input: { newPassword } });
window.alert('Password reset.');
} catch {
window.alert('Could not reset password. Make sure it is at least 8 characters.');
}
}
async function handleImportFile(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
if (
!window.confirm(
'Importing a backup permanently replaces all cities, users, and photos with the ' +
'contents of this file. This cannot be undone. Continue?',
)
) {
return;
}
setImporting(true);
try {
await backupApi.importBackup(file);
window.alert('Import complete. You will now be logged out — please log back in.');
await logout();
} catch {
window.alert('Could not import backup. Make sure the file is a CityTracker backup zip.');
} finally {
setImporting(false);
}
}
return (
<div className="admin-users-page">
<h1>Users</h1>
<table className="users-table">
<thead>
<tr>
<th>Username</th>
<th>Display name</th>
<th>Admin</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={5}>Loading…</td>
</tr>
)}
{(users ?? []).map((u) => (
<tr key={u.id} className={!u.isActive ? 'row-inactive' : ''}>
<td>{u.username}</td>
<td>{u.displayName || '—'}</td>
<td>
<button
type="button"
className="btn btn-small"
onClick={() => handleToggleAdmin(u.id, u.isAdmin)}
>
{u.isAdmin ? 'Demote' : 'Promote'}
</button>
</td>
<td>
<button
type="button"
className="btn btn-small"
onClick={() => handleToggleActive(u.id, u.isActive)}
>
{u.isActive ? 'Deactivate' : 'Reactivate'}
</button>
</td>
<td>
<button
type="button"
className="btn btn-small"
onClick={() => handleEditDisplayName(u.id, u.displayName)}
>
Edit name
</button>
<button
type="button"
className="btn btn-small"
onClick={() => handleResetPassword(u.id)}
>
Reset password
</button>
</td>
</tr>
))}
</tbody>
</table>
<h2>Add a user</h2>
<form className="create-user-form" onSubmit={handleCreate}>
<label>
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
/>
</label>
<label>
Display name
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</label>
<label className="like-toggle">
<input type="checkbox" checked={isAdmin} onChange={(e) => setIsAdmin(e.target.checked)} />
Admin
</label>
{error && <p className="form-error">{error}</p>}
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? 'Creating…' : 'Create user'}
</button>
</form>
<section className="backup-section">
<h2>Backup</h2>
<p className="backup-hint">
Export all cities, users, and photos as a zip file. Importing a backup permanently
replaces all existing data.
</p>
<div className="backup-actions">
<a className="btn" href={backupApi.EXPORT_BACKUP_URL} download>
Export backup
</a>
<label className={`btn btn-danger ${importing ? 'btn-disabled' : ''}`}>
{importing ? 'Importing…' : 'Import backup'}
<input
type="file"
accept=".zip"
hidden
disabled={importing}
onChange={handleImportFile}
/>
</label>
</div>
</section>
</div>
);
}