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
@@ -91,7 +91,9 @@ To Be Played is split into three repositories:
|
|||||||
- 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). "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.
|
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. The login tells a waiting user that the admin has not decided yet.
|
||||||
- **Dashboard hints**: news from the admin at the bottom of the dashboard, one at a time. A "Dismiss" hint closes
|
- **Dashboard hints**: news from the admin at the bottom of the dashboard, one at a time. A "Dismiss" hint closes
|
||||||
after 30 s or with X and comes back in the next browser session unless "Do not show again" is ticked; an
|
after 30 s or with X and comes back in the next browser session unless "Do not show again" is ticked; an
|
||||||
"Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them).
|
"Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them).
|
||||||
|
|||||||
+19
-3
@@ -16,7 +16,8 @@
|
|||||||
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen.",
|
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen.",
|
||||||
"ERROR_GENERIC": "Anmeldung fehlgeschlagen. Bitte später erneut versuchen.",
|
"ERROR_GENERIC": "Anmeldung fehlgeschlagen. Bitte später erneut versuchen.",
|
||||||
"SESSION_EXPIRED": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
|
"SESSION_EXPIRED": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
|
||||||
"ERROR_ACCOUNT_BLOCKED": "Dieses Konto ist gesperrt. Bitte wende dich an den Admin."
|
"ERROR_ACCOUNT_BLOCKED": "Dieses Konto ist gesperrt. Bitte wende dich an den Admin.",
|
||||||
|
"ERROR_ACCOUNT_PENDING": "Deine Registrierung wartet auf den Admin. Du bekommst eine Mail, sobald sie entschieden ist."
|
||||||
},
|
},
|
||||||
"REGISTRATION": {
|
"REGISTRATION": {
|
||||||
"TITLE": "Registrieren",
|
"TITLE": "Registrieren",
|
||||||
@@ -296,7 +297,9 @@
|
|||||||
"BADGE": "Ansicht als {{role}}",
|
"BADGE": "Ansicht als {{role}}",
|
||||||
"ERROR": "Die Rolle konnte nicht gewechselt werden."
|
"ERROR": "Die Rolle konnte nicht gewechselt werden."
|
||||||
},
|
},
|
||||||
"ADMIN_HINTS": "Hinweise"
|
"ADMIN_HINTS": "Hinweise",
|
||||||
|
"PENDING_REQUESTS_one": "{{count}} Registrierung wartet",
|
||||||
|
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten"
|
||||||
},
|
},
|
||||||
"BACKLOG": {
|
"BACKLOG": {
|
||||||
"TITLE": "Mein Backlog",
|
"TITLE": "Mein Backlog",
|
||||||
@@ -532,7 +535,20 @@
|
|||||||
"ERROR": "Die Aktion ist fehlgeschlagen.",
|
"ERROR": "Die Aktion ist fehlgeschlagen.",
|
||||||
"PAGE_OF": "Seite {{current}} von {{total}}",
|
"PAGE_OF": "Seite {{current}} von {{total}}",
|
||||||
"PREVIOUS": "Zurück",
|
"PREVIOUS": "Zurück",
|
||||||
"NEXT": "Weiter"
|
"NEXT": "Weiter",
|
||||||
|
"PENDING": "Wartet",
|
||||||
|
"DENIED": "Abgelehnt",
|
||||||
|
"PENDING_ONLY": "Nur wartende",
|
||||||
|
"ACCEPT": "Annehmen",
|
||||||
|
"DENY": "Ablehnen",
|
||||||
|
"ACCEPT_TITLE": "{{email}} annehmen?",
|
||||||
|
"ACCEPT_TEXT": "Der Nutzer kann sich sofort anmelden und bekommt eine Mail.",
|
||||||
|
"DENY_TITLE": "{{email}} ablehnen?",
|
||||||
|
"DENY_TEXT": "Der Nutzer bekommt eine Mail und kann sich nicht anmelden. Die Anfrage wird nach 30 Tagen endgültig gelöscht.",
|
||||||
|
"DONE_ACCEPT": "Registrierung angenommen",
|
||||||
|
"DONE_DENY": "Registrierung abgelehnt",
|
||||||
|
"ERROR_NOT_PENDING": "Diese Registrierung ist schon entschieden.",
|
||||||
|
"ERROR_TARGET_IS_PENDING": "Nimm die Registrierung erst an oder lehne sie ab."
|
||||||
},
|
},
|
||||||
"CORRECTION": {
|
"CORRECTION": {
|
||||||
"OPEN": "Daten korrigieren",
|
"OPEN": "Daten korrigieren",
|
||||||
|
|||||||
+19
-3
@@ -16,7 +16,8 @@
|
|||||||
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes.",
|
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes.",
|
||||||
"ERROR_GENERIC": "Login failed. Please try again later.",
|
"ERROR_GENERIC": "Login failed. Please try again later.",
|
||||||
"SESSION_EXPIRED": "Your session has expired. Please log in again.",
|
"SESSION_EXPIRED": "Your session has expired. Please log in again.",
|
||||||
"ERROR_ACCOUNT_BLOCKED": "This account is blocked. Please contact the admin."
|
"ERROR_ACCOUNT_BLOCKED": "This account is blocked. Please contact the admin.",
|
||||||
|
"ERROR_ACCOUNT_PENDING": "Your registration waits for the admin. You get a mail when it is decided."
|
||||||
},
|
},
|
||||||
"REGISTRATION": {
|
"REGISTRATION": {
|
||||||
"TITLE": "Register",
|
"TITLE": "Register",
|
||||||
@@ -296,7 +297,9 @@
|
|||||||
"BADGE": "Viewing as {{role}}",
|
"BADGE": "Viewing as {{role}}",
|
||||||
"ERROR": "The role could not be switched."
|
"ERROR": "The role could not be switched."
|
||||||
},
|
},
|
||||||
"ADMIN_HINTS": "Hints"
|
"ADMIN_HINTS": "Hints",
|
||||||
|
"PENDING_REQUESTS_one": "{{count}} registration waits",
|
||||||
|
"PENDING_REQUESTS_other": "{{count}} registrations wait"
|
||||||
},
|
},
|
||||||
"BACKLOG": {
|
"BACKLOG": {
|
||||||
"TITLE": "My backlog",
|
"TITLE": "My backlog",
|
||||||
@@ -532,7 +535,20 @@
|
|||||||
"ERROR": "The action failed.",
|
"ERROR": "The action failed.",
|
||||||
"PAGE_OF": "Page {{current}} of {{total}}",
|
"PAGE_OF": "Page {{current}} of {{total}}",
|
||||||
"PREVIOUS": "Previous",
|
"PREVIOUS": "Previous",
|
||||||
"NEXT": "Next"
|
"NEXT": "Next",
|
||||||
|
"PENDING": "Waits",
|
||||||
|
"DENIED": "Denied",
|
||||||
|
"PENDING_ONLY": "Waiting only",
|
||||||
|
"ACCEPT": "Accept",
|
||||||
|
"DENY": "Deny",
|
||||||
|
"ACCEPT_TITLE": "Accept {{email}}?",
|
||||||
|
"ACCEPT_TEXT": "The user can log in at once and gets a mail.",
|
||||||
|
"DENY_TITLE": "Deny {{email}}?",
|
||||||
|
"DENY_TEXT": "The user gets a mail and cannot log in. The request is removed for good after 30 days.",
|
||||||
|
"DONE_ACCEPT": "Registration accepted",
|
||||||
|
"DONE_DENY": "Registration denied",
|
||||||
|
"ERROR_NOT_PENDING": "This registration is decided already.",
|
||||||
|
"ERROR_TARGET_IS_PENDING": "Accept or deny the registration first."
|
||||||
},
|
},
|
||||||
"CORRECTION": {
|
"CORRECTION": {
|
||||||
"OPEN": "Correct data",
|
"OPEN": "Correct data",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {useEffect, useState} from 'react';
|
||||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Settings, Shield, Sun} from 'lucide-react';
|
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 SteamAvatar from './SteamAvatar.jsx';
|
||||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||||
import useSteamProfile from '../hooks/useSteamProfile.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';
|
import {expireSession, markSession} from '../utils/auth.js';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
@@ -49,6 +50,24 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isAdmin = currentUser?.role === 'PLATFORM_ADMIN';
|
const isAdmin = currentUser?.role === 'PLATFORM_ADMIN';
|
||||||
const viewingAs = currentUser && currentUser.activeRole !== currentUser.role ? currentUser.activeRole : null;
|
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) => {
|
const switchRole = async (role) => {
|
||||||
try {
|
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>
|
<Badge variant="secondary" className="mr-1">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge>
|
||||||
)}
|
)}
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu onOpenChange={(open) => open && setMenuOpenings((count) => count + 1)}>
|
||||||
<DropdownMenuTrigger asChild>
|
<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}/>
|
<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>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-52">
|
<DropdownMenuContent align="end" className="w-52">
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
|
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{isAdmin && !viewingAs && (
|
{seesAdminPage && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem asChild>
|
<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>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to="/admin/hints"><Megaphone/>{t('NAV.ADMIN_HINTS')}</Link>
|
<Link to="/admin/hints"><Megaphone/>{t('NAV.ADMIN_HINTS')}</Link>
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ describe('AppHeader', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('offers the admin page and "view as" to an admin', async () => {
|
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 screen.findByRole('button', {name: 'NAV.MENU'});
|
||||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
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(await screen.findByText('NAV.ADMIN')).toBeTruthy();
|
||||||
expect(screen.getByText('NAV.VIEW_AS.MENU')).toBeTruthy();
|
expect(screen.getByText('NAV.VIEW_AS.MENU')).toBeTruthy();
|
||||||
expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull();
|
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 () => {
|
it('shows the badge and hides the admin page while an admin views as a user', async () => {
|
||||||
|
|||||||
@@ -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, 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 {toast} from 'sonner';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
@@ -18,7 +18,7 @@ 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 {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
import {decideUser, deleteUser, fetchUsers, 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';
|
||||||
|
|
||||||
@@ -27,21 +27,25 @@ const REFUSAL_KEY = {
|
|||||||
SELF_ACTION: 'ADMIN.ERROR_SELF_ACTION',
|
SELF_ACTION: 'ADMIN.ERROR_SELF_ACTION',
|
||||||
TARGET_IS_ADMIN: 'ADMIN.ERROR_TARGET_IS_ADMIN',
|
TARGET_IS_ADMIN: 'ADMIN.ERROR_TARGET_IS_ADMIN',
|
||||||
ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED',
|
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);
|
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() {
|
export default function AdminUsersPage() {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
|
const [pendingOnly, setPendingOnly] = useState(false);
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [status, setStatus] = useState('loading');
|
const [status, setStatus] = useState('loading');
|
||||||
// {kind: 'block' | 'delete', user}
|
// {kind: 'block' | 'delete' | 'accept' | 'deny', user}
|
||||||
const [pending, setPending] = useState(null);
|
const [pending, setPending] = useState(null);
|
||||||
const [typedEmail, setTypedEmail] = useState('');
|
const [typedEmail, setTypedEmail] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -51,7 +55,7 @@ export default function AdminUsersPage() {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const signal = nextSignal();
|
const signal = nextSignal();
|
||||||
try {
|
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 (signal.aborted) return;
|
||||||
if (response.status === 401) return;
|
if (response.status === 401) return;
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
@@ -71,7 +75,7 @@ export default function AdminUsersPage() {
|
|||||||
console.error('Admin users error:', err);
|
console.error('Admin users error:', err);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
}, [nextSignal, search, page]);
|
}, [nextSignal, search, pendingOnly, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -82,14 +86,35 @@ export default function AdminUsersPage() {
|
|||||||
setTypedEmail('');
|
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 confirm = async () => {
|
||||||
const {kind, user} = pending;
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
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.status === 401) return;
|
||||||
if (response.ok) {
|
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();
|
closeDialog();
|
||||||
await load();
|
await load();
|
||||||
} else {
|
} else {
|
||||||
@@ -127,6 +152,13 @@ export default function AdminUsersPage() {
|
|||||||
<Input aria-label={t('ADMIN.SEARCH')} placeholder={t('ADMIN.SEARCH')} value={query}
|
<Input aria-label={t('ADMIN.SEARCH')} placeholder={t('ADMIN.SEARCH')} value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}/>
|
onChange={(event) => setQuery(event.target.value)}/>
|
||||||
<Button type="submit" variant="outline" size="icon" aria-label={t('ADMIN.SEARCH')}><Search/></Button>
|
<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>
|
</form>
|
||||||
)}
|
)}
|
||||||
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
|
{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">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
{user.role !== 'USER' && <Badge variant="outline">{t(`ROLES.${user.role}`)}</Badge>}
|
{user.role !== 'USER' && <Badge variant="outline">{t(`ROLES.${user.role}`)}</Badge>}
|
||||||
{user.steamLinked && <Badge variant="secondary">{t('ADMIN.STEAM')}</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.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>
|
</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">
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="outline" size="sm"
|
<Button type="button" variant="outline" size="sm"
|
||||||
onClick={() => setPending({kind: 'block', user})}>
|
onClick={() => setPending({kind: 'block', user})}>
|
||||||
@@ -189,14 +238,8 @@ export default function AdminUsersPage() {
|
|||||||
{pending && (
|
{pending && (
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>{dialog.title}</DialogTitle>
|
||||||
{pending.kind === 'delete'
|
<DialogDescription>{dialog.text}</DialogDescription>
|
||||||
? 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>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{pending.kind === 'delete' && (
|
{pending.kind === 'delete' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -209,10 +252,10 @@ export default function AdminUsersPage() {
|
|||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button type="button" variant="outline">{t('ADMIN.CANCEL')}</Button>
|
<Button type="button" variant="outline">{t('ADMIN.CANCEL')}</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button type="button" variant={pending.kind === 'delete' ? 'destructive' : 'default'}
|
<Button type="button" variant={dialog.destructive ? 'destructive' : 'default'}
|
||||||
disabled={confirmDisabled} onClick={confirm}>
|
disabled={confirmDisabled} onClick={confirm}>
|
||||||
{busy && <Loader2 className="animate-spin"/>}
|
{busy && <Loader2 className="animate-spin"/>}
|
||||||
{t(pending.kind === 'delete' ? 'ADMIN.DELETE' : pending.user.blocked ? 'ADMIN.UNBLOCK' : 'ADMIN.BLOCK')}
|
{t(dialog.action)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
|||||||
|
|
||||||
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',
|
||||||
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 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});
|
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'));
|
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 () => {
|
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();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const LOGIN_ERROR_KEY = {
|
|||||||
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
|
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
|
||||||
TOO_MANY_ATTEMPTS: 'LOGIN.ERROR_TOO_MANY_ATTEMPTS',
|
TOO_MANY_ATTEMPTS: 'LOGIN.ERROR_TOO_MANY_ATTEMPTS',
|
||||||
ACCOUNT_BLOCKED: 'LOGIN.ERROR_ACCOUNT_BLOCKED',
|
ACCOUNT_BLOCKED: 'LOGIN.ERROR_ACCOUNT_BLOCKED',
|
||||||
|
ACCOUNT_PENDING: 'LOGIN.ERROR_ACCOUNT_PENDING',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ describe('LoginPage', () => {
|
|||||||
expect(screen.queryByText('LOGIN.SESSION_EXPIRED')).toBeNull();
|
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 () => {
|
it('says that a blocked account is blocked', async () => {
|
||||||
fetchRoutes({'POST /api/auth/login': refused(403, 'ACCOUNT_BLOCKED')});
|
fetchRoutes({'POST /api/auth/login': refused(403, 'ACCOUNT_BLOCKED')});
|
||||||
renderLogin();
|
renderLogin();
|
||||||
|
|||||||
+17
-3
@@ -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).
|
* 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) =>
|
export const fetchUsers = (query, status, page, size, init) =>
|
||||||
apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, 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) =>
|
export const setUserBlocked = (userId, blocked) =>
|
||||||
apiFetch(`${AUTH()}/api/admin/users/${userId}/blocked`, {
|
apiFetch(`${AUTH()}/api/admin/users/${userId}/blocked`, {
|
||||||
|
|||||||
Reference in new issue
Block a user