Public Access
feat(admin): accept, deny and pending count
This commit is contained in:
1 parent
b8f70e7578
commit
f4e69e1a94
10 files changed
+230
-38
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react';
|
||||
import {Ban, Check, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2, X} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
||||
import {decideUser, deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||
|
||||
@@ -27,21 +27,25 @@ 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',
|
||||
};
|
||||
|
||||
const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null);
|
||||
|
||||
/**
|
||||
* PLATFORM_ADMIN only: list, block and delete users. Auth checks the role; a 403 shows "no access".
|
||||
* PLATFORM_ADMIN only: list, block and delete users, accept or deny registrations that wait. Auth checks the role; a
|
||||
* 403 shows "no access".
|
||||
*/
|
||||
export default function AdminUsersPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [pendingOnly, setPendingOnly] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [status, setStatus] = useState('loading');
|
||||
// {kind: 'block' | 'delete', user}
|
||||
// {kind: 'block' | 'delete' | 'accept' | 'deny', user}
|
||||
const [pending, setPending] = useState(null);
|
||||
const [typedEmail, setTypedEmail] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -51,7 +55,7 @@ export default function AdminUsersPage() {
|
||||
const load = useCallback(async () => {
|
||||
const signal = nextSignal();
|
||||
try {
|
||||
const response = await fetchUsers(search, page, PAGE_SIZE, {signal});
|
||||
const response = await fetchUsers(search, pendingOnly ? 'PENDING' : '', page, PAGE_SIZE, {signal});
|
||||
if (signal.aborted) return;
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 403) {
|
||||
@@ -71,7 +75,7 @@ export default function AdminUsersPage() {
|
||||
console.error('Admin users error:', err);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [nextSignal, search, page]);
|
||||
}, [nextSignal, search, pendingOnly, page]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -82,14 +86,35 @@ export default function AdminUsersPage() {
|
||||
setTypedEmail('');
|
||||
};
|
||||
|
||||
const email = pending?.user.email;
|
||||
const dialog = pending && {
|
||||
delete: {
|
||||
title: t('ADMIN.DELETE_TITLE', {email}), text: t('ADMIN.DELETE_TEXT'), action: 'ADMIN.DELETE',
|
||||
done: 'ADMIN.DONE_DELETE', destructive: true, run: () => deleteUser(pending.user.id),
|
||||
},
|
||||
block: {
|
||||
...(pending.user.blocked
|
||||
? {title: t('ADMIN.UNBLOCK'), text: email, action: 'ADMIN.UNBLOCK', done: 'ADMIN.DONE_UNBLOCK'}
|
||||
: {title: t('ADMIN.BLOCK_TITLE', {email}), text: t('ADMIN.BLOCK_TEXT'), action: 'ADMIN.BLOCK', done: 'ADMIN.DONE_BLOCK'}),
|
||||
run: () => setUserBlocked(pending.user.id, !pending.user.blocked),
|
||||
},
|
||||
accept: {
|
||||
title: t('ADMIN.ACCEPT_TITLE', {email}), text: t('ADMIN.ACCEPT_TEXT'), action: 'ADMIN.ACCEPT',
|
||||
done: 'ADMIN.DONE_ACCEPT', run: () => decideUser(pending.user.id, true),
|
||||
},
|
||||
deny: {
|
||||
title: t('ADMIN.DENY_TITLE', {email}), text: t('ADMIN.DENY_TEXT'), action: 'ADMIN.DENY',
|
||||
done: 'ADMIN.DONE_DENY', destructive: true, run: () => decideUser(pending.user.id, false),
|
||||
},
|
||||
}[pending.kind];
|
||||
|
||||
const confirm = async () => {
|
||||
const {kind, user} = pending;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = kind === 'delete' ? await deleteUser(user.id) : await setUserBlocked(user.id, !user.blocked);
|
||||
const response = await dialog.run();
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t(kind === 'delete' ? 'ADMIN.DONE_DELETE' : user.blocked ? 'ADMIN.DONE_UNBLOCK' : 'ADMIN.DONE_BLOCK'));
|
||||
toast.success(t(dialog.done));
|
||||
closeDialog();
|
||||
await load();
|
||||
} else {
|
||||
@@ -127,6 +152,13 @@ export default function AdminUsersPage() {
|
||||
<Input aria-label={t('ADMIN.SEARCH')} placeholder={t('ADMIN.SEARCH')} value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}/>
|
||||
<Button type="submit" variant="outline" size="icon" aria-label={t('ADMIN.SEARCH')}><Search/></Button>
|
||||
<Button type="button" variant={pendingOnly ? 'default' : 'outline'} aria-pressed={pendingOnly}
|
||||
onClick={() => {
|
||||
setPage(0);
|
||||
setPendingOnly(!pendingOnly);
|
||||
}}>
|
||||
{t('ADMIN.PENDING_ONLY')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
|
||||
@@ -149,10 +181,27 @@ export default function AdminUsersPage() {
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{user.role !== 'USER' && <Badge variant="outline">{t(`ROLES.${user.role}`)}</Badge>}
|
||||
{user.steamLinked && <Badge variant="secondary">{t('ADMIN.STEAM')}</Badge>}
|
||||
{user.status === 'PENDING' && <Badge>{t('ADMIN.PENDING')}</Badge>}
|
||||
{user.blocked && <Badge variant="destructive">{t('ADMIN.BLOCKED')}</Badge>}
|
||||
{user.deletedAt && <Badge variant="destructive">{t('ADMIN.DELETED')}</Badge>}
|
||||
{user.deletedAt && (
|
||||
<Badge variant="destructive">
|
||||
{t(user.status === 'DENIED' ? 'ADMIN.DENIED' : 'ADMIN.DELETED')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && (
|
||||
{user.status === 'PENDING' && (
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
onClick={() => setPending({kind: 'accept', user})}>
|
||||
<Check/>{t('ADMIN.ACCEPT')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" className="border-destructive text-destructive"
|
||||
onClick={() => setPending({kind: 'deny', user})}>
|
||||
<X/>{t('ADMIN.DENY')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && (
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
onClick={() => setPending({kind: 'block', user})}>
|
||||
@@ -189,14 +238,8 @@ export default function AdminUsersPage() {
|
||||
{pending && (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{pending.kind === 'delete'
|
||||
? t('ADMIN.DELETE_TITLE', {email: pending.user.email})
|
||||
: pending.user.blocked ? t('ADMIN.UNBLOCK') : t('ADMIN.BLOCK_TITLE', {email: pending.user.email})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pending.kind === 'delete' ? t('ADMIN.DELETE_TEXT') : pending.user.blocked ? pending.user.email : t('ADMIN.BLOCK_TEXT')}
|
||||
</DialogDescription>
|
||||
<DialogTitle>{dialog.title}</DialogTitle>
|
||||
<DialogDescription>{dialog.text}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{pending.kind === 'delete' && (
|
||||
<div className="space-y-2">
|
||||
@@ -209,10 +252,10 @@ export default function AdminUsersPage() {
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">{t('ADMIN.CANCEL')}</Button>
|
||||
</DialogClose>
|
||||
<Button type="button" variant={pending.kind === 'delete' ? 'destructive' : 'default'}
|
||||
<Button type="button" variant={dialog.destructive ? 'destructive' : 'default'}
|
||||
disabled={confirmDisabled} onClick={confirm}>
|
||||
{busy && <Loader2 className="animate-spin"/>}
|
||||
{t(pending.kind === 'delete' ? 'ADMIN.DELETE' : pending.user.blocked ? 'ADMIN.UNBLOCK' : 'ADMIN.BLOCK')}
|
||||
{t(dialog.action)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -12,8 +12,11 @@ vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
|
||||
const PLAYER = {
|
||||
id: 7, email: 'player@example.com', displayName: 'Player', role: 'USER', registrationDate: '2026-09-01T10:00:00',
|
||||
lastLoginDate: null, steamLinked: true, blocked: false, deletedAt: null,
|
||||
lastLoginDate: null, steamLinked: true, blocked: false, deletedAt: null, status: 'ACTIVE',
|
||||
};
|
||||
const NEWCOMER = {...PLAYER, id: 8, email: 'new@example.com', steamLinked: false, status: 'PENDING'};
|
||||
const DENIED = {...PLAYER, id: 9, email: 'denied@example.com', steamLinked: false, status: 'DENIED',
|
||||
deletedAt: '2026-10-01T10:00:00Z'};
|
||||
const ADMIN = {...PLAYER, id: 1, email: 'admin@example.com', role: 'PLATFORM_ADMIN', steamLinked: false};
|
||||
|
||||
const page = (content, totalPages = 1) => ({content, page: 0, size: 20, totalElements: content.length, totalPages});
|
||||
@@ -96,6 +99,46 @@ describe('AdminUsersPage', () => {
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_TARGET_IS_ADMIN'));
|
||||
});
|
||||
|
||||
it('accepts or denies a registration that waits, and offers no block on it', async () => {
|
||||
withRoutes({
|
||||
'GET /api/admin/users': json(200, page([NEWCOMER])),
|
||||
'PUT /api/admin/users/8/decision': json(204),
|
||||
});
|
||||
renderPage();
|
||||
expect(await screen.findByText('ADMIN.PENDING')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', {name: 'ADMIN.BLOCK'})).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.ACCEPT'}));
|
||||
fireEvent.click(screen.getAllByRole('button', {name: 'ADMIN.ACCEPT'}).at(-1));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_ACCEPT'));
|
||||
expect(JSON.parse(calls('PUT')[0][1].body)).toEqual({accepted: true});
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'ADMIN.DENY'}));
|
||||
fireEvent.click(screen.getAllByRole('button', {name: 'ADMIN.DENY'}).at(-1));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_DENY'));
|
||||
expect(JSON.parse(calls('PUT')[1][1].body)).toEqual({accepted: false});
|
||||
});
|
||||
|
||||
it('shows a denied registration as denied, with no action', async () => {
|
||||
withRoutes({'GET /api/admin/users': json(200, page([DENIED]))});
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('ADMIN.DENIED')).toBeTruthy();
|
||||
expect(screen.queryByText('ADMIN.DELETED')).toBeNull();
|
||||
expect(screen.queryByRole('button', {name: 'ADMIN.DELETE'})).toBeNull();
|
||||
});
|
||||
|
||||
it('lists only the waiting registrations on request, from the first page', async () => {
|
||||
renderPage();
|
||||
await screen.findByText('player@example.com');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.PENDING_ONLY'}));
|
||||
|
||||
await waitFor(() => expect(calls('GET').at(-1)[0]).toContain('status=PENDING&page=0'));
|
||||
});
|
||||
|
||||
it('says no access to a user without the admin role', async () => {
|
||||
withRoutes({'GET /api/admin/users': json(403, {})});
|
||||
renderPage();
|
||||
|
||||
@@ -15,6 +15,7 @@ const LOGIN_ERROR_KEY = {
|
||||
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
|
||||
TOO_MANY_ATTEMPTS: 'LOGIN.ERROR_TOO_MANY_ATTEMPTS',
|
||||
ACCOUNT_BLOCKED: 'LOGIN.ERROR_ACCOUNT_BLOCKED',
|
||||
ACCOUNT_PENDING: 'LOGIN.ERROR_ACCOUNT_PENDING',
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
|
||||
@@ -85,6 +85,15 @@ describe('LoginPage', () => {
|
||||
expect(screen.queryByText('LOGIN.SESSION_EXPIRED')).toBeNull();
|
||||
});
|
||||
|
||||
it('says that an account waits for the admin', async () => {
|
||||
fetchRoutes({'POST /api/auth/login': refused(403, 'ACCOUNT_PENDING')});
|
||||
renderLogin();
|
||||
|
||||
submit();
|
||||
|
||||
expect(await screen.findByText('LOGIN.ERROR_ACCOUNT_PENDING')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('says that a blocked account is blocked', async () => {
|
||||
fetchRoutes({'POST /api/auth/login': refused(403, 'ACCOUNT_BLOCKED')});
|
||||
renderLogin();
|
||||
|
||||
Reference in new issue
Block a user