feat(admin): accept, deny and pending count

This commit is contained in:
lobo committed 2026-10-01 09:18:47 +02:00
1 parent b8f70e7578
commit f4e69e1a94
10 files changed
+230 -38

No files matched your search

+34 -5
View File
@@ -1,3 +1,4 @@
import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Settings, Shield, Sun} from 'lucide-react';
@@ -22,7 +23,7 @@ import PageContainer from './PageContainer.jsx';
import SteamAvatar from './SteamAvatar.jsx';
import useCurrentUser from '../hooks/useCurrentUser.js';
import useSteamProfile from '../hooks/useSteamProfile.js';
import {ROLES, viewAs} from '../utils/admin.js';
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
import {expireSession, markSession} from '../utils/auth.js';
const NAV_ITEMS = [
@@ -49,6 +50,24 @@ export default function AppHeader({isAuthenticated, onLogout}) {
const navigate = useNavigate();
const isAdmin = currentUser?.role === 'PLATFORM_ADMIN';
const viewingAs = currentUser && currentUser.activeRole !== currentUser.role ? currentUser.activeRole : null;
const seesAdminPage = isAdmin && !viewingAs;
const [pendingCount, setPendingCount] = useState(0);
const [menuOpenings, setMenuOpenings] = useState(0);
// Loaded again on every page change and menu opening, so a decision on the admin page shows soon.
useEffect(() => {
if (!seesAdminPage) return undefined;
let current = true;
fetchPendingCount()
.then(async (response) => {
if (current && response.ok) setPendingCount((await response.json()).count);
})
.catch((err) => console.error('Pending count error:', err));
return () => {
current = false;
};
}, [seesAdminPage, pathname, menuOpenings]);
const shownCount = seesAdminPage ? pendingCount : 0;
const switchRole = async (role) => {
try {
@@ -108,20 +127,30 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<Badge variant="secondary" className="mr-1">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge>
)}
{isAuthenticated ? (
<DropdownMenu>
<DropdownMenu onOpenChange={(open) => open && setMenuOpenings((count) => count + 1)}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('NAV.MENU')} title={steamProfile?.personaName ?? undefined}>
<Button variant="ghost" size="icon" aria-label={t('NAV.MENU')} title={steamProfile?.personaName ?? undefined}
className="relative">
<SteamAvatar url={steamProfile?.avatarUrl}/>
{shownCount > 0 && (
<span aria-label={t('NAV.PENDING_REQUESTS', {count: shownCount})}
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{shownCount > 9 ? '9+' : shownCount}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem asChild>
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
</DropdownMenuItem>
{isAdmin && !viewingAs && (
{seesAdminPage && (
<>
<DropdownMenuItem asChild>
<Link to="/admin/users"><Shield/>{t('NAV.ADMIN')}</Link>
<Link to="/admin/users">
<Shield/>{t('NAV.ADMIN')}
{shownCount > 0 && <Badge className="ml-auto">{shownCount}</Badge>}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/hints"><Megaphone/>{t('NAV.ADMIN_HINTS')}</Link>
+20 -1
View File
@@ -46,7 +46,8 @@ describe('AppHeader', () => {
});
it('offers the admin page and "view as" to an admin', async () => {
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'});
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
{'GET /api/admin/users/pending-count': json(200, {count: 0})});
await screen.findByRole('button', {name: 'NAV.MENU'});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
@@ -55,6 +56,24 @@ describe('AppHeader', () => {
expect(await screen.findByText('NAV.ADMIN')).toBeTruthy();
expect(screen.getByText('NAV.VIEW_AS.MENU')).toBeTruthy();
expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull();
expect(screen.queryByLabelText('NAV.PENDING_REQUESTS:0')).toBeNull();
});
it('shows the admin how many registrations wait', async () => {
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
{'GET /api/admin/users/pending-count': json(200, {count: 3})});
expect(await screen.findByLabelText('NAV.PENDING_REQUESTS:3')).toBeTruthy();
openMenu();
expect((await screen.findByRole('menuitem', {name: /^NAV\.ADMIN ?3$/})).textContent).toContain('3');
});
it('shows no count when Auth does not know the call yet', async () => {
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
{'GET /api/admin/users/pending-count': json(404, {})});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('pending-count'), expect.anything()));
expect(screen.queryByLabelText(/NAV.PENDING_REQUESTS/)).toBeNull();
});
it('shows the badge and hides the admin page while an admin views as a user', async () => {
+64 -21
View File
@@ -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>
+44 -1
View File
@@ -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();
+1
View File
@@ -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() {
+9
View File
@@ -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();
+17 -3
View File
@@ -6,12 +6,26 @@ export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN'];
/**
* GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise).
*
* @param status PENDING, ACTIVE or DENIED; empty for all
*/
export const fetchUsers = (query, page, size, init) =>
apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, page, size})}`, init);
export const fetchUsers = (query, status, page, size, init) =>
apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, ...(status && {status}), page, size})}`, init);
export const fetchPendingCount = () => apiFetch(`${AUTH()}/api/admin/users/pending-count`);
/**
* 204, or 409 with code SELF_ACTION, TARGET_IS_ADMIN or ALREADY_DELETED.
* Accept or deny a registration that waits: 204, or 409 NOT_PENDING when it is decided already.
*/
export const decideUser = (userId, accepted) =>
apiFetch(`${AUTH()}/api/admin/users/${userId}/decision`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({accepted}),
});
/**
* 204, or 409 with code SELF_ACTION, TARGET_IS_ADMIN, ALREADY_DELETED or TARGET_IS_PENDING.
*/
export const setUserBlocked = (userId, blocked) =>
apiFetch(`${AUTH()}/api/admin/users/${userId}/blocked`, {