Compare commits

..
2 Commits
Author SHA1 Message Date
lobo 79b086c525 feat(registration): no invite code, request text
Build, Test & Push Frontend / docker (push) Successful in 22s
Build, Test & Push Frontend / quality-check (push) Successful in 22s
2026-10-01 09:18:47 +02:00
lobo f4e69e1a94 feat(admin): accept, deny and pending count 2026-10-01 09:18:47 +02:00
14 changed files with 285 additions and 80 deletions

No files matched your search

+5 -1
View File
@@ -85,13 +85,17 @@ To Be Played is split into three repositories:
- **Language**: kept in the browser and saved in Auth; after the login Auth's language wins when one is saved. Dark - **Language**: kept in the browser and saved in Auth; after the login Auth's language wins when one is saved. Dark
mode stays per device. The registration sends the browser language (`de` for a German browser, else `en`): Auth mode stays per device. The registration sends the browser language (`de` for a German browser, else `en`): Auth
writes its mails in it and saves it as the user's language. writes its mails in it and saves it as the user's language.
- **Registration** (`/register`): no invite code; the page says that an admin checks each new account and stays on
"registration sent" after the send. Auth answers `409 PENDING_LIMIT_REACHED` while 20 wait.
- **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported - **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported
to Core once per page load (`POST /api/library/broken-images`, only with a session). Core repairs it at night; to Core once per page load (`POST /api/library/broken-images`, only with a session). Core repairs it at night;
a repaired cover comes back as `imageUrl`. a repaired cover comes back as `imageUrl`.
- 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).
+23 -8
View File
@@ -16,23 +16,24 @@
"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",
"EMAIL": "Email", "EMAIL": "Email",
"PASSWORD": "Passwort", "PASSWORD": "Passwort",
"PASSWORD_CONFIRMATION": "Passwort bestätigen", "PASSWORD_CONFIRMATION": "Passwort bestätigen",
"INVITE_CODE": "Einladungscode",
"SUBMIT": "Konto erstellen", "SUBMIT": "Konto erstellen",
"SUCCESS": "Konto erstellt. Weiterleitung zum Login …", "SUCCESS": "Registrierung gesendet. Du bekommst eine Mail, sobald der Admin entschieden hat.",
"HAVE_ACCOUNT": "Bereits ein Konto?", "HAVE_ACCOUNT": "Bereits ein Konto?",
"ERROR_GENERIC": "Registrierung fehlgeschlagen. Bitte erneut versuchen.", "ERROR_GENERIC": "Registrierung fehlgeschlagen. Bitte erneut versuchen.",
"ERROR_EMAIL_IN_USE": "Diese Email-Adresse ist bereits registriert.", "ERROR_EMAIL_IN_USE": "Diese Email-Adresse ist bereits registriert.",
"ERROR_INVALID_INVITE_CODE": "Der Einladungscode ist ungültig oder wurde bereits verwendet.",
"DISPLAY_NAME": "Anzeigename (optional)", "DISPLAY_NAME": "Anzeigename (optional)",
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.", "ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.",
"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.",
"INTRO": "Ein Admin prüft jedes neue Konto. Du bekommst eine Mail, sobald über deins entschieden ist.",
"ERROR_PENDING_LIMIT": "Gerade warten zu viele Registrierungen auf den Admin. Bitte versuch es später noch einmal."
}, },
"SETTINGS": { "SETTINGS": {
"PROFILE_STYLE": "Aussehen des Profils", "PROFILE_STYLE": "Aussehen des Profils",
@@ -137,7 +138,6 @@
"PASSWORD_TOO_LONG": "Passwort ist zu lang: höchstens 72 Zeichen, mit Umlauten oder Emoji weniger.", "PASSWORD_TOO_LONG": "Passwort ist zu lang: höchstens 72 Zeichen, mit Umlauten oder Emoji weniger.",
"PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.", "PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.",
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.", "PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.",
"INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.",
"DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)." "DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)."
}, },
"INFO": { "INFO": {
@@ -296,7 +296,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 +534,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",
+23 -8
View File
@@ -16,23 +16,24 @@
"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",
"EMAIL": "Email", "EMAIL": "Email",
"PASSWORD": "Password", "PASSWORD": "Password",
"PASSWORD_CONFIRMATION": "Confirm password", "PASSWORD_CONFIRMATION": "Confirm password",
"INVITE_CODE": "Invite code",
"SUBMIT": "Create account", "SUBMIT": "Create account",
"SUCCESS": "Account created. Redirecting to login …", "SUCCESS": "Registration sent. You get a mail as soon as the admin has decided.",
"HAVE_ACCOUNT": "Already have an account?", "HAVE_ACCOUNT": "Already have an account?",
"ERROR_GENERIC": "Registration failed. Please try again.", "ERROR_GENERIC": "Registration failed. Please try again.",
"ERROR_EMAIL_IN_USE": "This email address is already registered.", "ERROR_EMAIL_IN_USE": "This email address is already registered.",
"ERROR_INVALID_INVITE_CODE": "The invite code is invalid or has already been used.",
"DISPLAY_NAME": "Display name (optional)", "DISPLAY_NAME": "Display name (optional)",
"ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.", "ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.",
"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.",
"INTRO": "An admin checks each new account. You get a mail as soon as yours is decided.",
"ERROR_PENDING_LIMIT": "Too many registrations wait for the admin right now. Please try again later."
}, },
"SETTINGS": { "SETTINGS": {
"PROFILE_STYLE": "Profile look", "PROFILE_STYLE": "Profile look",
@@ -137,7 +138,6 @@
"PASSWORD_TOO_LONG": "Password is too long: at most 72 characters, fewer with umlauts or emoji.", "PASSWORD_TOO_LONG": "Password is too long: at most 72 characters, fewer with umlauts or emoji.",
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.", "PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
"PASSWORD_MISMATCH": "Passwords do not match.", "PASSWORD_MISMATCH": "Passwords do not match.",
"INVITE_CODE_REQUIRED": "Invite code is required.",
"DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)." "DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)."
}, },
"INFO": { "INFO": {
@@ -296,7 +296,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 +534,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",
+34 -5
View File
@@ -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>
+20 -1
View File
@@ -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 () => {
+64 -21
View File
@@ -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>
+44 -1
View File
@@ -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();
+1
View File
@@ -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() {
+9
View File
@@ -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();
+8 -17
View File
@@ -1,5 +1,5 @@
import {useState} from 'react'; import {useState} from 'react';
import {Link, useNavigate} from 'react-router-dom'; import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form'; import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod'; import {zodResolver} from '@hookform/resolvers/zod';
@@ -17,15 +17,16 @@ import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
const BACKEND_ERROR_KEY = { const BACKEND_ERROR_KEY = {
EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE', EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE',
INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE', PENDING_LIMIT_REACHED: 'REGISTRATION.ERROR_PENDING_LIMIT',
DISPLAY_NAME_ALREADY_IN_USE: 'REGISTRATION.ERROR_DISPLAY_NAME_IN_USE', DISPLAY_NAME_ALREADY_IN_USE: 'REGISTRATION.ERROR_DISPLAY_NAME_IN_USE',
TOO_MANY_ATTEMPTS: 'REGISTRATION.ERROR_TOO_MANY_ATTEMPTS', TOO_MANY_ATTEMPTS: 'REGISTRATION.ERROR_TOO_MANY_ATTEMPTS',
}; };
export default function RegistrationPage() { export default function RegistrationPage() {
const {t} = useTranslation(); const {t} = useTranslation();
const navigate = useNavigate();
const [feedback, setFeedback] = useState({message: '', isError: false}); const [feedback, setFeedback] = useState({message: '', isError: false});
// The request waits for the admin; a second one would only be refused.
const [requested, setRequested] = useState(false);
const { const {
register, register,
@@ -37,7 +38,6 @@ export default function RegistrationPage() {
email: '', email: '',
password: '', password: '',
passwordConfirmation: '', passwordConfirmation: '',
inviteCode: '',
displayName: '', displayName: '',
}, },
}); });
@@ -52,7 +52,6 @@ export default function RegistrationPage() {
body: JSON.stringify({ body: JSON.stringify({
email: values.email, email: values.email,
password: values.password, password: values.password,
inviteCode: values.inviteCode,
displayName: values.displayName, displayName: values.displayName,
// Auth's mails use it: a German browser gets German, every other one English. // Auth's mails use it: a German browser gets German, every other one English.
language: navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en', language: navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en',
@@ -61,7 +60,7 @@ export default function RegistrationPage() {
if (response.ok) { if (response.ok) {
setFeedback({message: t('REGISTRATION.SUCCESS'), isError: false}); setFeedback({message: t('REGISTRATION.SUCCESS'), isError: false});
setTimeout(() => navigate('/login'), 800); setRequested(true);
return; return;
} }
@@ -90,7 +89,8 @@ export default function RegistrationPage() {
</Link> </Link>
</>} </>}
> >
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate> <p className="mb-4 text-sm text-muted-foreground">{t('REGISTRATION.INTRO')}</p>
{!requested && <form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<FormField id="email" label={t('REGISTRATION.EMAIL')} error={errors.email} t={t}> <FormField id="email" label={t('REGISTRATION.EMAIL')} error={errors.email} t={t}>
<Input <Input
id="email" id="email"
@@ -121,15 +121,6 @@ export default function RegistrationPage() {
/> />
</FormField> </FormField>
<FormField id="inviteCode" label={t('REGISTRATION.INVITE_CODE')} error={errors.inviteCode} t={t}>
<Input
id="inviteCode"
type="text"
aria-invalid={!!errors.inviteCode}
{...register('inviteCode')}
/>
</FormField>
<FormField id="displayName" label={t('REGISTRATION.DISPLAY_NAME')} error={errors.displayName} t={t}> <FormField id="displayName" label={t('REGISTRATION.DISPLAY_NAME')} error={errors.displayName} t={t}>
<Input <Input
id="displayName" id="displayName"
@@ -144,7 +135,7 @@ export default function RegistrationPage() {
{isSubmitting && <Loader2 className="animate-spin"/>} {isSubmitting && <Loader2 className="animate-spin"/>}
{t('REGISTRATION.SUBMIT')} {t('REGISTRATION.SUBMIT')}
</Button> </Button>
</form> </form>}
<FeedbackAlert message={feedback.message} isError={feedback.isError}/> <FeedbackAlert message={feedback.message} isError={feedback.isError}/>
</AuthLayout> </AuthLayout>
+35 -8
View File
@@ -3,7 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react'; import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import RegistrationPage from './RegistrationPage.jsx'; import RegistrationPage from './RegistrationPage.jsx';
import {fetchRoutes} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
// Own mock: the shared one drops the {{minutes}} value this test checks. // Own mock: the shared one drops the {{minutes}} value this test checks.
vi.mock('react-i18next', () => ({ vi.mock('react-i18next', () => ({
@@ -23,7 +23,6 @@ function submitValidForm() {
fireEvent.change(screen.getByLabelText('REGISTRATION.EMAIL'), {target: {value: 'a@b.de'}}); fireEvent.change(screen.getByLabelText('REGISTRATION.EMAIL'), {target: {value: 'a@b.de'}});
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD'), {target: {value: 'secret123'}}); fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD'), {target: {value: 'secret123'}});
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD_CONFIRMATION'), {target: {value: 'secret123'}}); fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD_CONFIRMATION'), {target: {value: 'secret123'}});
fireEvent.change(screen.getByLabelText('REGISTRATION.INVITE_CODE'), {target: {value: 'code'}});
fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.SUBMIT'})); fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.SUBMIT'}));
} }
@@ -42,11 +41,11 @@ describe('RegistrationPage', () => {
}); });
it('asks for no Steam ID and sends none', async () => { it('asks for no Steam ID and sends none', async () => {
answerRegistration(400, 'INVALID_INVITATION_CODE'); answerRegistration(409, 'EMAIL_ALREADY_IN_USE');
submitValidForm(); submitValidForm();
await screen.findByText('REGISTRATION.ERROR_INVALID_INVITE_CODE'); await screen.findByText('REGISTRATION.ERROR_EMAIL_IN_USE');
expect(screen.queryByLabelText('REGISTRATION.STEAM_ID')).toBeNull(); expect(screen.queryByLabelText('REGISTRATION.STEAM_ID')).toBeNull();
expect(JSON.parse(fetch.mock.calls[0][1].body)).not.toHaveProperty('steamId'); expect(JSON.parse(fetch.mock.calls[0][1].body)).not.toHaveProperty('steamId');
}); });
@@ -54,20 +53,48 @@ describe('RegistrationPage', () => {
it.each([['de-DE', 'de'], ['de', 'de'], ['en-US', 'en'], ['fr-FR', 'en'], [undefined, 'en']])( it.each([['de-DE', 'de'], ['de', 'de'], ['en-US', 'en'], ['fr-FR', 'en'], [undefined, 'en']])(
'sends the browser language %s as %s, for the mails', async (browser, sent) => { 'sends the browser language %s as %s, for the mails', async (browser, sent) => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue(browser); vi.spyOn(navigator, 'language', 'get').mockReturnValue(browser);
answerRegistration(400, 'INVALID_INVITATION_CODE'); answerRegistration(409, 'EMAIL_ALREADY_IN_USE');
submitValidForm(); submitValidForm();
await screen.findByText('REGISTRATION.ERROR_INVALID_INVITE_CODE'); await screen.findByText('REGISTRATION.ERROR_EMAIL_IN_USE');
expect(JSON.parse(fetch.mock.calls[0][1].body).language).toBe(sent); expect(JSON.parse(fetch.mock.calls[0][1].body).language).toBe(sent);
}); });
it('maps a known backend error to its message', async () => { it('maps a known backend error to its message', async () => {
answerRegistration(400, 'INVALID_INVITATION_CODE'); answerRegistration(409, 'EMAIL_ALREADY_IN_USE');
submitValidForm(); submitValidForm();
expect(await screen.findByText('REGISTRATION.ERROR_INVALID_INVITE_CODE')).toBeTruthy(); expect(await screen.findByText('REGISTRATION.ERROR_EMAIL_IN_USE')).toBeTruthy();
});
it('asks for no invite code and sends none', async () => {
answerRegistration(409, 'EMAIL_ALREADY_IN_USE');
submitValidForm();
await screen.findByText('REGISTRATION.ERROR_EMAIL_IN_USE');
expect(screen.queryByLabelText('REGISTRATION.INVITE_CODE')).toBeNull();
expect(JSON.parse(fetch.mock.calls[0][1].body)).not.toHaveProperty('inviteCode');
});
it('says the request waits for the admin and offers no second send', async () => {
fetchRoutes({'POST /api/registration/new': json(200, {userId: 5})});
submitValidForm();
expect(await screen.findByText('REGISTRATION.SUCCESS')).toBeTruthy();
expect(screen.queryByRole('button', {name: 'REGISTRATION.SUBMIT'})).toBeNull();
expect(screen.getByText('REGISTRATION.INTRO')).toBeTruthy();
});
it('says so when too many registrations wait', async () => {
answerRegistration(409, 'PENDING_LIMIT_REACHED');
submitValidForm();
expect(await screen.findByText('REGISTRATION.ERROR_PENDING_LIMIT')).toBeTruthy();
}); });
it('shows its own text, not the English one of the server, for a refused input', async () => { it('shows its own text, not the English one of the server, for a refused input', async () => {
+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). * 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`, {
-5
View File
@@ -13,10 +13,6 @@ const passwordField = z
.min(8, 'VALIDATION.PASSWORD_TOO_SHORT') .min(8, 'VALIDATION.PASSWORD_TOO_SHORT')
.refine((value) => new TextEncoder().encode(value).length <= 72, 'VALIDATION.PASSWORD_TOO_LONG'); .refine((value) => new TextEncoder().encode(value).length <= 72, 'VALIDATION.PASSWORD_TOO_LONG');
const inviteCodeField = z
.string()
.min(1, 'VALIDATION.INVITE_CODE_REQUIRED');
// Same rule as the Auth service: 3-24 letters, digits, space, _ . - and no space at the ends. // Same rule as the Auth service: 3-24 letters, digits, space, _ . - and no space at the ends.
// An empty field means "no display name" and is sent as null. // An empty field means "no display name" and is sent as null.
const displayNameField = z const displayNameField = z
@@ -33,7 +29,6 @@ export const registrationSchema = z
email: emailField, email: emailField,
password: passwordField, password: passwordField,
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'), passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
inviteCode: inviteCodeField,
displayName: displayNameField, displayName: displayNameField,
}) })
.refine((d) => d.password === d.passwordConfirmation, { .refine((d) => d.password === d.passwordConfirmation, {
+2 -2
View File
@@ -7,7 +7,7 @@ describe('Steam ID', () => {
it('is no form field any more: it comes only from "Sign in through Steam"', () => { it('is no form field any more: it comes only from "Sign in through Steam"', () => {
expect(settingsSchema.parse({...settings(), steamId: '76561198005679788'})).not.toHaveProperty('steamId'); expect(settingsSchema.parse({...settings(), steamId: '76561198005679788'})).not.toHaveProperty('steamId');
expect(registrationSchema.parse({ expect(registrationSchema.parse({
email: 'a@b.de', password: 'password1', passwordConfirmation: 'password1', inviteCode: 'code', email: 'a@b.de', password: 'password1', passwordConfirmation: 'password1',
steamId: '76561198005679788', steamId: '76561198005679788',
})).not.toHaveProperty('steamId'); })).not.toHaveProperty('steamId');
}); });
@@ -34,7 +34,7 @@ describe('registration password', () => {
it('rejects a password over 72 UTF-8 bytes, as Auth does', () => { it('rejects a password over 72 UTF-8 bytes, as Auth does', () => {
const password = 'é'.repeat(37); const password = 'é'.repeat(37);
expect(registrationSchema.safeParse({ expect(registrationSchema.safeParse({
email: 'a@b.de', password, passwordConfirmation: password, inviteCode: 'code', email: 'a@b.de', password, passwordConfirmation: password,
}).success).toBe(false); }).success).toBe(false);
}); });
}); });