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.
241 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|