feat(admin): add user role dialog

Admins change a user's role and see the change log. The last admin
gets a clear error when deleting their own account.
This commit is contained in:
lobo committed 2026-10-04 20:09:22 +02:00
1 parent e5091de50e
commit 13b0d5e7e9
11 files changed
+391 -18

No files matched your search

+2
View File
@@ -48,6 +48,8 @@ export default function DeleteAccountCard({email}) {
}
if (response.status === 403) {
toast.error(t('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD'));
} else if (response.status === 409) {
toast.error(t('SETTINGS.DELETE_ACCOUNT.LAST_ADMIN'));
} else if (response.status === 429) {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
toast.error(t('SETTINGS.DELETE_ACCOUNT.TOO_MANY', {minutes}));
+12
View File
@@ -78,6 +78,18 @@ describe('DeleteAccountCard', () => {
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('tells the last admin to make another admin first and stays on the page', async () => {
deleteAnswers(json(409, {code: 'LAST_ADMIN'}));
renderCard();
openAndFill(EMAIL, 'secret');
fireEvent.click(confirmButton());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.DELETE_ACCOUNT.LAST_ADMIN'));
expect(confirmButton()).toBeTruthy();
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('shows the lock message after too many wrong passwords', async () => {
deleteAnswers({...json(429, {}), headers: new Headers({'Retry-After': '900'})});
renderCard();
+122
View File
@@ -0,0 +1,122 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {changeUserRole, fetchRoleChanges, REFUSAL_KEY, ROLES} from '../utils/admin.js';
import {formatDate} from '../utils/format.js';
function RoleDialogContent({user, onClose, onChanged}) {
const {t, i18n} = useTranslation();
const [chosen, setChosen] = useState(user.role);
const [busy, setBusy] = useState(false);
// null while loading, 'error' when it failed
const [changes, setChanges] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetchRoleChanges(user.id, {signal: controller.signal})
.then(async (response) => {
if (controller.signal.aborted || response.status === 401) return;
setChanges(response.ok ? (await response.json()).results : 'error');
})
.catch((err) => {
if (controller.signal.aborted) return;
console.error('Role changes error:', err);
setChanges('error');
});
return () => controller.abort();
}, [user.id]);
const save = async () => {
setBusy(true);
try {
const response = await changeUserRole(user.id, chosen, user.role);
if (response.status === 401) return;
if (response.ok) {
toast.success(t('ADMIN.DONE_ROLE'));
onChanged();
onClose();
return;
}
const data = await response.json().catch(() => ({}));
toast.error(t(REFUSAL_KEY[data.code] ?? 'ADMIN.ERROR'));
if (data.code === 'ROLE_CHANGED') {
// The dialog holds the old user; reopen it from the reloaded list.
onChanged();
onClose();
}
} catch (err) {
console.error('Role change error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle>{t('ADMIN.ROLES_TITLE', {email: user.email})}</DialogTitle>
<DialogDescription>{t('ADMIN.ROLES_TEXT')}</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap gap-2">
{ROLES.map((role) => (
<Button key={role} type="button" variant={chosen === role ? 'default' : 'outline'}
aria-pressed={chosen === role} onClick={() => setChosen(role)}>
{t(`ROLES.${role}`)}
</Button>
))}
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{t('ADMIN.ROLE_HISTORY')}</p>
{changes === null && <Loader2 className="size-4 animate-spin text-muted-foreground"/>}
{changes === 'error' && <p className="text-sm text-destructive">{t('ADMIN.ROLE_HISTORY_ERROR')}</p>}
{Array.isArray(changes) && changes.length === 0 && (
<p className="text-sm text-muted-foreground">{t('ADMIN.ROLE_HISTORY_EMPTY')}</p>
)}
{Array.isArray(changes) && changes.length > 0 && (
<ul className="max-h-48 space-y-1 overflow-y-auto text-sm">
{changes.map((change, index) => (
<li key={index} className="flex flex-wrap gap-x-2 text-muted-foreground">
<span>{formatDate(change.changedAt, i18n.language)}</span>
<span>{change.actorEmail ?? `#${change.actorUserId}`}</span>
<span>{`${t(`ROLES.${change.oldRole}`)} → ${t(`ROLES.${change.newRole}`)}`}</span>
</li>
))}
</ul>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t('ADMIN.CANCEL')}</Button>
</DialogClose>
<Button type="button" disabled={busy || chosen === user.role} onClick={save}>
{busy && <Loader2 className="animate-spin"/>}
{t('ADMIN.SAVE_ROLE')}
</Button>
</DialogFooter>
</DialogContent>
);
}
/**
* Sets the role of another user and shows its role changes. Open while `user` is set.
*/
export default function UserRoleDialog({user, onClose, onChanged}) {
return (
<Dialog open={!!user} onOpenChange={(open) => { if (!open) onClose(); }}>
{user && <RoleDialogContent user={user} onClose={onClose} onChanged={onChanged}/>}
</Dialog>
);
}
+95
View File
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import UserRoleDialog from './UserRoleDialog.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const USER = {id: 7, email: 'player@example.com', role: 'USER'};
const LOG = {results: [{actorUserId: 1, actorEmail: 'admin@example.com', oldRole: 'USER', newRole: 'AUDITOR',
changedAt: '2026-10-01T10:00:00Z'}]};
const withRoutes = (routes) => fetchRoutes({'GET /api/admin/users/7/role-changes': json(200, LOG), ...routes});
const renderDialog = () => {
const onClose = vi.fn();
const onChanged = vi.fn();
render(<UserRoleDialog user={USER} onClose={onClose} onChanged={onChanged}/>);
return {onClose, onChanged};
};
const save = () => screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'});
const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT');
describe('UserRoleDialog', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
withRoutes({'PUT /api/admin/users/7/role': json(204)});
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
it('shows the change log of the user', async () => {
renderDialog();
expect(await screen.findByText('admin@example.com')).toBeTruthy();
expect(screen.getByText('ROLES.USER → ROLES.AUDITOR')).toBeTruthy();
});
it('says so when the log is empty or cannot be loaded', async () => {
withRoutes({'GET /api/admin/users/7/role-changes': json(200, {results: []})});
renderDialog();
expect(await screen.findByText('ADMIN.ROLE_HISTORY_EMPTY')).toBeTruthy();
cleanup();
withRoutes({'GET /api/admin/users/7/role-changes': json(500, {})});
renderDialog();
expect(await screen.findByText('ADMIN.ROLE_HISTORY_ERROR')).toBeTruthy();
});
it('starts at the current role and cannot save an unchanged role', () => {
renderDialog();
expect(screen.getByRole('button', {name: 'ROLES.USER'}).getAttribute('aria-pressed')).toBe('true');
expect(save().disabled).toBe(true);
});
it('saves the chosen role with the role it saw, then reloads and closes', async () => {
const {onChanged, onClose} = renderDialog();
fireEvent.click(screen.getByRole('button', {name: 'ROLES.AUDITOR'}));
fireEvent.click(save());
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(JSON.parse(puts()[0][1].body)).toEqual({role: 'AUDITOR', expectedRole: 'USER'});
expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_ROLE');
expect(onClose).toHaveBeenCalled();
});
it('shows why Auth refuses', async () => {
withRoutes({'PUT /api/admin/users/7/role': json(409, {code: 'ACTOR_NOT_ADMIN'})});
const {onChanged} = renderDialog();
fireEvent.click(screen.getByRole('button', {name: 'ROLES.AUDITOR'}));
fireEvent.click(save());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_ACTOR_NOT_ADMIN'));
expect(onChanged).not.toHaveBeenCalled();
});
it('loads the list again when the role changed in the meantime', async () => {
withRoutes({'PUT /api/admin/users/7/role': json(409, {code: 'ROLE_CHANGED'})});
const {onChanged} = renderDialog();
fireEvent.click(screen.getByRole('button', {name: 'ROLES.AUDITOR'}));
fireEvent.click(save());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_ROLE_CHANGED'));
expect(onChanged).toHaveBeenCalled();
});
});