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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user