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(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) { 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 (

Users

{isLoading && ( )} {(users ?? []).map((u) => ( ))}
Username Display name Admin Active Actions
Loading…
{u.username} {u.displayName || '—'}

Add a user

{error &&

{error}

}

Backup

Export all cities, users, and photos as a zip file. Importing a backup permanently replaces all existing data.

Export backup
); }