Public Access
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:
1 parent
e5091de50e
commit
13b0d5e7e9
11 files changed
+391
-18
No files matched your search
@@ -119,7 +119,8 @@ To Be Played is split into three repositories:
|
||||
a repaired cover comes back as `imageUrl`.
|
||||
- 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
|
||||
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
|
||||
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.
|
||||
|
||||
+14
-2
@@ -166,7 +166,8 @@
|
||||
"DONE": "Dein Konto ist gelöscht.",
|
||||
"WRONG_PASSWORD": "Das Passwort ist falsch.",
|
||||
"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\".",
|
||||
"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.",
|
||||
"DONE_PASSWORD_RESET": "Reset-Mail gesendet.",
|
||||
"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": {
|
||||
"OPEN": "Daten korrigieren",
|
||||
|
||||
+14
-2
@@ -166,7 +166,8 @@
|
||||
"DONE": "Your account is deleted.",
|
||||
"WRONG_PASSWORD": "The password is wrong.",
|
||||
"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\".",
|
||||
"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.",
|
||||
"DONE_PASSWORD_RESET": "Reset mail sent.",
|
||||
"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": {
|
||||
"OPEN": "Correct data",
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
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 {Badge} from '@/components/ui/badge';
|
||||
@@ -18,21 +18,13 @@ import {
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
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 useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||
|
||||
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);
|
||||
|
||||
@@ -54,6 +46,8 @@ export default function AdminUsersPage() {
|
||||
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.
|
||||
const nextSignal = useAbortPrevious();
|
||||
const ownId = useCurrentUserContext().user?.id;
|
||||
const [roleUser, setRoleUser] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const signal = nextSignal();
|
||||
@@ -223,6 +217,11 @@ export default function AdminUsersPage() {
|
||||
</Button>
|
||||
</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' && (
|
||||
<div className="flex gap-2">
|
||||
{user.status === 'ACTIVE' && !user.blocked && (
|
||||
@@ -289,6 +288,7 @@ export default function AdminUsersPage() {
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
<UserRoleDialog user={roleUser} onClose={() => setRoleUser(null)} onChanged={load}/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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()}}));
|
||||
vi.mock('../context/CurrentUserContext.jsx', () => ({useCurrentUserContext: () => ({user: {id: 1}})}));
|
||||
|
||||
const PLAYER = {
|
||||
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$/);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
withRoutes({'GET /api/admin/users': json(403, {})});
|
||||
renderPage();
|
||||
|
||||
@@ -4,6 +4,20 @@ const AUTH = () => import.meta.env.VITE_API_URL;
|
||||
|
||||
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).
|
||||
*
|
||||
@@ -34,6 +48,23 @@ export const setUserBlocked = (userId, 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.
|
||||
*/
|
||||
|
||||
+25
-1
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {refreshSession} from './api.js';
|
||||
import {viewAs} from './admin.js';
|
||||
import {changeUserRole, fetchRoleChanges, viewAs} from './admin.js';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
describe('viewAs', () => {
|
||||
@@ -25,3 +25,27 @@ describe('viewAs', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user