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 -1
View File
@@ -119,7 +119,8 @@ To Be Played is split into three repositories:
a repaired cover comes back as `imageUrl`. a repaired cover comes back as `imageUrl`.
- German and English, dark mode, mobile layout, session-expired message instead of a silent logout. - German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
- **Admin** (PLATFORM_ADMIN only, `/admin/users`): search the users, block, unblock and delete them (the email is - **Admin** (PLATFORM_ADMIN only, `/admin/users`): search the users, block, unblock and delete them (the email is
typed to confirm a delete). "View as" in the menu switches the own session to User or Auditor and back; a badge typed to confirm a delete), and set the role of another user in the "Roles" dialog, which also lists the role
changes. "View as" in the menu switches the own session to User or Auditor and back; a badge
shows the lower role. Auth checks every right on the server. Registrations that wait show "Waits" with Accept shows the lower role. Auth checks every right on the server. Registrations that wait show "Waits" with Accept
and Deny (a "Waiting only" filter lists just them); the menu shows how many wait, loaded again on every page change and Deny (a "Waiting only" filter lists just them); the menu shows how many wait, loaded again on every page change
and menu opening. A request whose address is not confirmed shows "Not confirmed" and only a Delete button. The login tells a waiting user that the admin has not decided yet. and menu opening. A request whose address is not confirmed shows "Not confirmed" and only a Delete button. The login tells a waiting user that the admin has not decided yet.
+14 -2
View File
@@ -166,7 +166,8 @@
"DONE": "Dein Konto ist gelöscht.", "DONE": "Dein Konto ist gelöscht.",
"WRONG_PASSWORD": "Das Passwort ist falsch.", "WRONG_PASSWORD": "Das Passwort ist falsch.",
"TOO_MANY": "Zu viele falsche Passwörter. Bitte in {{minutes}} Minuten erneut versuchen.", "TOO_MANY": "Zu viele falsche Passwörter. Bitte in {{minutes}} Minuten erneut versuchen.",
"ERROR": "Das Konto konnte nicht gelöscht werden. Bitte später erneut versuchen." "ERROR": "Das Konto konnte nicht gelöscht werden. Bitte später erneut versuchen.",
"LAST_ADMIN": "Du bist der letzte Admin. Mache zuerst einen anderen Nutzer zum Admin."
}, },
"PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".", "PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".",
"FAVORITE_PLATFORM": "Lieblingsplattform", "FAVORITE_PLATFORM": "Lieblingsplattform",
@@ -647,7 +648,18 @@
"PASSWORD_RESET_TEXT": "Die Person bekommt eine Mail mit einem Link, um ein neues Passwort zu wählen (1 Stunde gültig). Du siehst das Passwort nie.", "PASSWORD_RESET_TEXT": "Die Person bekommt eine Mail mit einem Link, um ein neues Passwort zu wählen (1 Stunde gültig). Du siehst das Passwort nie.",
"DONE_PASSWORD_RESET": "Reset-Mail gesendet.", "DONE_PASSWORD_RESET": "Reset-Mail gesendet.",
"ERROR_NOT_PENDING": "Diese Registrierung ist schon entschieden.", "ERROR_NOT_PENDING": "Diese Registrierung ist schon entschieden.",
"ERROR_TARGET_IS_PENDING": "Nimm die Registrierung erst an oder lehne sie ab." "ERROR_TARGET_IS_PENDING": "Nimm die Registrierung erst an oder lehne sie ab.",
"ROLES": "Rollen",
"ROLES_TITLE": "Rollen von {{email}}",
"ROLES_TEXT": "Die Änderung wirkt nach etwa 30 Sekunden. Der Nutzer muss die Seite eventuell neu laden.",
"SAVE_ROLE": "Speichern",
"DONE_ROLE": "Rolle gespeichert.",
"ROLE_HISTORY": "Änderungen",
"ROLE_HISTORY_EMPTY": "Noch keine Rollenänderungen.",
"ROLE_HISTORY_ERROR": "Die Änderungen konnten nicht geladen werden.",
"ERROR_ROLE_CHANGED": "Die Rolle hat sich inzwischen geändert. Die Liste wird neu geladen.",
"ERROR_ACTOR_NOT_ADMIN": "Du bist kein Admin mehr.",
"ERROR_TARGET_IS_BLOCKED": "Entsperre den Nutzer zuerst."
}, },
"CORRECTION": { "CORRECTION": {
"OPEN": "Daten korrigieren", "OPEN": "Daten korrigieren",
+14 -2
View File
@@ -166,7 +166,8 @@
"DONE": "Your account is deleted.", "DONE": "Your account is deleted.",
"WRONG_PASSWORD": "The password is wrong.", "WRONG_PASSWORD": "The password is wrong.",
"TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.", "TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.",
"ERROR": "The account could not be deleted. Please try again later." "ERROR": "The account could not be deleted. Please try again later.",
"LAST_ADMIN": "You are the last admin. Make another user admin first."
}, },
"PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".", "PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".",
"FAVORITE_PLATFORM": "Favorite platform", "FAVORITE_PLATFORM": "Favorite platform",
@@ -647,7 +648,18 @@
"PASSWORD_RESET_TEXT": "The user gets a mail with a link to choose a new password (valid 1 hour). You never see the password.", "PASSWORD_RESET_TEXT": "The user gets a mail with a link to choose a new password (valid 1 hour). You never see the password.",
"DONE_PASSWORD_RESET": "Reset mail sent.", "DONE_PASSWORD_RESET": "Reset mail sent.",
"ERROR_NOT_PENDING": "This registration is decided already.", "ERROR_NOT_PENDING": "This registration is decided already.",
"ERROR_TARGET_IS_PENDING": "Accept or deny the registration first." "ERROR_TARGET_IS_PENDING": "Accept or deny the registration first.",
"ROLES": "Roles",
"ROLES_TITLE": "Roles of {{email}}",
"ROLES_TEXT": "The change works within about 30 seconds. The user may need to reload the page.",
"SAVE_ROLE": "Save",
"DONE_ROLE": "Role saved.",
"ROLE_HISTORY": "Changes",
"ROLE_HISTORY_EMPTY": "No role changes yet.",
"ROLE_HISTORY_ERROR": "The changes could not be loaded.",
"ERROR_ROLE_CHANGED": "The role changed in the meantime. The list is loaded again.",
"ERROR_ACTOR_NOT_ADMIN": "You are no admin any more.",
"ERROR_TARGET_IS_BLOCKED": "Unblock the user first."
}, },
"CORRECTION": { "CORRECTION": {
"OPEN": "Correct data", "OPEN": "Correct data",
+2
View File
@@ -48,6 +48,8 @@ export default function DeleteAccountCard({email}) {
} }
if (response.status === 403) { if (response.status === 403) {
toast.error(t('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD')); 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) { } else if (response.status === 429) {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60); const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
toast.error(t('SETTINGS.DELETE_ACCOUNT.TOO_MANY', {minutes})); 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'); 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 () => { it('shows the lock message after too many wrong passwords', async () => {
deleteAnswers({...json(429, {}), headers: new Headers({'Retry-After': '900'})}); deleteAnswers({...json(429, {}), headers: new Headers({'Retry-After': '900'})});
renderCard(); 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();
});
});
+12 -12
View File
@@ -1,6 +1,6 @@
import {useCallback, useEffect, useState} from 'react'; import {useCallback, useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Ban, Check, ChevronLeft, ChevronRight, KeyRound, Loader2, Search, ShieldCheck, Trash2, X} from 'lucide-react'; import {Ban, Check, ChevronLeft, ChevronRight, KeyRound, Loader2, Search, ShieldCheck, Trash2, UserCog, X} from 'lucide-react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
@@ -18,21 +18,13 @@ import {
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label'; import {Label} from '@/components/ui/label';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, sendPasswordReset, setUserBlocked} from '../utils/admin.js'; import UserRoleDialog from '../components/UserRoleDialog.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, REFUSAL_KEY, sendPasswordReset, setUserBlocked} from '../utils/admin.js';
import {formatDate} from '../utils/format.js'; import {formatDate} from '../utils/format.js';
import useAbortPrevious from '../hooks/useAbortPrevious.js'; import useAbortPrevious from '../hooks/useAbortPrevious.js';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const REFUSAL_KEY = {
SELF_ACTION: 'ADMIN.ERROR_SELF_ACTION',
TARGET_IS_ADMIN: 'ADMIN.ERROR_TARGET_IS_ADMIN',
ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED',
NOT_PENDING: 'ADMIN.ERROR_NOT_PENDING',
TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING',
TARGET_IS_UNCONFIRMED: 'ADMIN.ERROR_TARGET_IS_UNCONFIRMED',
NOT_UNCONFIRMED: 'ADMIN.ERROR_NOT_UNCONFIRMED',
NOT_ACTIVE: 'ADMIN.ERROR_NOT_ACTIVE',
};
const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null); const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null);
@@ -54,6 +46,8 @@ export default function AdminUsersPage() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Only the newest load may show its answer: a slow answer to an older search must not replace the list. // Only the newest load may show its answer: a slow answer to an older search must not replace the list.
const nextSignal = useAbortPrevious(); const nextSignal = useAbortPrevious();
const ownId = useCurrentUserContext().user?.id;
const [roleUser, setRoleUser] = useState(null);
const load = useCallback(async () => { const load = useCallback(async () => {
const signal = nextSignal(); const signal = nextSignal();
@@ -223,6 +217,11 @@ export default function AdminUsersPage() {
</Button> </Button>
</div> </div>
)} )}
{user.status === 'ACTIVE' && !user.blocked && !user.deletedAt && user.id !== ownId && (
<Button type="button" variant="outline" size="sm" onClick={() => setRoleUser(user)}>
<UserCog/>{t('ADMIN.ROLES')}
</Button>
)}
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && user.status !== 'UNCONFIRMED' && ( {user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && user.status !== 'UNCONFIRMED' && (
<div className="flex gap-2"> <div className="flex gap-2">
{user.status === 'ACTIVE' && !user.blocked && ( {user.status === 'ACTIVE' && !user.blocked && (
@@ -289,6 +288,7 @@ export default function AdminUsersPage() {
</DialogContent> </DialogContent>
)} )}
</Dialog> </Dialog>
<UserRoleDialog user={roleUser} onClose={() => setRoleUser(null)} onChanged={load}/>
</PageContainer> </PageContainer>
); );
} }
+62
View File
@@ -9,6 +9,7 @@ import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('../context/CurrentUserContext.jsx', () => ({useCurrentUserContext: () => ({user: {id: 1}})}));
const PLAYER = { const PLAYER = {
id: 7, email: 'player@example.com', displayName: 'Player', role: 'USER', registrationDate: '2026-09-01T10:00:00', id: 7, email: 'player@example.com', displayName: 'Player', role: 'USER', registrationDate: '2026-09-01T10:00:00',
@@ -187,6 +188,67 @@ describe('AdminUsersPage', () => {
expect(calls('DELETE')[0][0]).toMatch(/\/api\/admin\/users\/10\/unconfirmed$/); expect(calls('DELETE')[0][0]).toMatch(/\/api\/admin\/users\/10\/unconfirmed$/);
}); });
it('offers the roles of an active user and of another admin, not of the own, blocked, deleted or waiting rows', async () => {
withRoutes({
'GET /api/admin/users': json(200, page([ADMIN, {...ADMIN, id: 2, email: 'admin2@example.com'}, PLAYER,
{...PLAYER, id: 11, email: 'blocked@example.com', blocked: true}, DENIED, NEWCOMER, UNCONFIRMED])),
});
renderPage();
await screen.findByText('player@example.com');
expect(screen.getAllByRole('button', {name: 'ADMIN.ROLES'})).toHaveLength(2);
});
it('saves a new role from the dialog and loads the list again', async () => {
withRoutes({
'GET /api/admin/users/7/role-changes': json(200, {results: []}),
'PUT /api/admin/users/7/role': json(204),
});
renderPage();
await screen.findByText('player@example.com');
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.ROLES'}));
fireEvent.click(await screen.findByRole('button', {name: 'ROLES.AUDITOR'}));
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'}));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_ROLE'));
expect(JSON.parse(calls('PUT')[0][1].body)).toEqual({role: 'AUDITOR', expectedRole: 'USER'});
await waitFor(() => expect(calls('GET').filter(([url]) => url.endsWith('/api/admin/users') || url.includes('/api/admin/users?'))).toHaveLength(2));
});
it('afterRoleChangedTheNextSaveSendsTheReloadedRole', async () => {
let lists = 0;
let puts = 0;
withRoutes({
'GET /api/admin/users': () => {
lists += 1;
return json(200, page([ADMIN, lists === 1 ? PLAYER : {...PLAYER, role: 'AUDITOR'}]));
},
'GET /api/admin/users/7/role-changes': json(200, {results: []}),
'PUT /api/admin/users/7/role': () => {
puts += 1;
return puts === 1 ? json(409, {code: 'ROLE_CHANGED'}) : json(204);
},
});
renderPage();
await screen.findByText('player@example.com');
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.ROLES'}));
fireEvent.click(await screen.findByRole('button', {name: 'ROLES.PLATFORM_ADMIN'}));
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_ROLE_CHANGED'));
await waitFor(() => expect(lists).toBe(2));
if (!screen.queryByRole('button', {name: 'ADMIN.SAVE_ROLE'})) {
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.ROLES'}));
}
fireEvent.click(await screen.findByRole('button', {name: 'ROLES.PLATFORM_ADMIN'}));
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'}));
await waitFor(() => expect(calls('PUT')).toHaveLength(2));
expect(JSON.parse(calls('PUT')[1][1].body)).toEqual({role: 'PLATFORM_ADMIN', expectedRole: 'AUDITOR'});
});
it('says no access to a user without the admin role', async () => { it('says no access to a user without the admin role', async () => {
withRoutes({'GET /api/admin/users': json(403, {})}); withRoutes({'GET /api/admin/users': json(403, {})});
renderPage(); renderPage();
+31
View File
@@ -4,6 +4,20 @@ const AUTH = () => import.meta.env.VITE_API_URL;
export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN']; export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN'];
export const REFUSAL_KEY = {
SELF_ACTION: 'ADMIN.ERROR_SELF_ACTION',
TARGET_IS_ADMIN: 'ADMIN.ERROR_TARGET_IS_ADMIN',
ACTOR_NOT_ADMIN: 'ADMIN.ERROR_ACTOR_NOT_ADMIN',
ROLE_CHANGED: 'ADMIN.ERROR_ROLE_CHANGED',
TARGET_IS_BLOCKED: 'ADMIN.ERROR_TARGET_IS_BLOCKED',
ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED',
NOT_PENDING: 'ADMIN.ERROR_NOT_PENDING',
TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING',
TARGET_IS_UNCONFIRMED: 'ADMIN.ERROR_TARGET_IS_UNCONFIRMED',
NOT_UNCONFIRMED: 'ADMIN.ERROR_NOT_UNCONFIRMED',
NOT_ACTIVE: 'ADMIN.ERROR_NOT_ACTIVE',
};
/** /**
* GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise). * GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise).
* *
@@ -34,6 +48,23 @@ export const setUserBlocked = (userId, blocked) =>
body: JSON.stringify({blocked}), body: JSON.stringify({blocked}),
}); });
/**
* Sets the role of another user. `expectedRole` is the role the admin saw. 204, or 409 with code SELF_ACTION,
* ACTOR_NOT_ADMIN, ROLE_CHANGED, TARGET_IS_BLOCKED, ALREADY_DELETED, TARGET_IS_PENDING or TARGET_IS_UNCONFIRMED.
*/
export const changeUserRole = (userId, role, expectedRole) =>
apiFetch(`${AUTH()}/api/admin/users/${userId}/role`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({role, expectedRole}),
});
/**
* GET the role changes of a user, newest first: {results: [{actorUserId, actorEmail, oldRole, newRole, changedAt}]}.
* `actorEmail` is null after the actor is purged.
*/
export const fetchRoleChanges = (userId, init) => apiFetch(`${AUTH()}/api/admin/users/${userId}/role-changes`, init);
/** /**
* Hard delete of a request whose address is not confirmed: 204, or 409 NOT_UNCONFIRMED for any other status. * Hard delete of a request whose address is not confirmed: 204, or 409 NOT_UNCONFIRMED for any other status.
*/ */
+25 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {refreshSession} from './api.js'; import {refreshSession} from './api.js';
import {viewAs} from './admin.js'; import {changeUserRole, fetchRoleChanges, viewAs} from './admin.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
describe('viewAs', () => { describe('viewAs', () => {
@@ -25,3 +25,27 @@ describe('viewAs', () => {
await switching; await switching;
}); });
}); });
describe('role changes', () => {
it('changeUserRole puts the new and the expected role', async () => {
fetchRoutes({'PUT /api/admin/users/7/role': json(204)});
await changeUserRole(7, 'AUDITOR', 'USER');
const [url, init] = fetch.mock.calls[0];
expect(url).toMatch(/\/api\/admin\/users\/7\/role$/);
expect(init.method).toBe('PUT');
expect(JSON.parse(init.body)).toEqual({role: 'AUDITOR', expectedRole: 'USER'});
});
it('fetchRoleChanges gets the log of the user', async () => {
fetchRoutes({'GET /api/admin/users/7/role-changes': json(200, {results: []})});
const signal = new AbortController().signal;
await fetchRoleChanges(7, {signal});
const [url, init] = fetch.mock.calls[0];
expect(url).toMatch(/\/api\/admin\/users\/7\/role-changes$/);
expect(init.signal).toBe(signal);
});
});