diff --git a/README.md b/README.md index 6fe1865..c8fe2fb 100644 --- a/README.md +++ b/README.md @@ -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. - **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 - 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 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). diff --git a/public/i18n/de.json b/public/i18n/de.json index e1f27b8..5db29e5 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -16,7 +16,8 @@ "ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen.", "ERROR_GENERIC": "Anmeldung fehlgeschlagen. Bitte später erneut versuchen.", "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": { "TITLE": "Registrieren", @@ -296,7 +297,9 @@ "BADGE": "Ansicht als {{role}}", "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": { "TITLE": "Mein Backlog", @@ -532,7 +535,20 @@ "ERROR": "Die Aktion ist fehlgeschlagen.", "PAGE_OF": "Seite {{current}} von {{total}}", "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": { "OPEN": "Daten korrigieren", diff --git a/public/i18n/en.json b/public/i18n/en.json index 6e304cb..282e9a4 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -16,7 +16,8 @@ "ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes.", "ERROR_GENERIC": "Login failed. Please try again later.", "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": { "TITLE": "Register", @@ -296,7 +297,9 @@ "BADGE": "Viewing as {{role}}", "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": { "TITLE": "My backlog", @@ -532,7 +535,20 @@ "ERROR": "The action failed.", "PAGE_OF": "Page {{current}} of {{total}}", "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": { "OPEN": "Correct data", diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index 83120c7..a795591 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -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}) { {t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})} )} {isAuthenticated ? ( - + open && setMenuOpenings((count) => count + 1)}> - {t('NAV.SETTINGS')} - {isAdmin && !viewingAs && ( + {seesAdminPage && ( <> - {t('NAV.ADMIN')} + + {t('NAV.ADMIN')} + {shownCount > 0 && {shownCount}} + {t('NAV.ADMIN_HINTS')} diff --git a/src/components/AppHeader.test.jsx b/src/components/AppHeader.test.jsx index ad84ad5..50c7fe5 100644 --- a/src/components/AppHeader.test.jsx +++ b/src/components/AppHeader.test.jsx @@ -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 () => { diff --git a/src/pages/AdminUsersPage.jsx b/src/pages/AdminUsersPage.jsx index e214e26..c3e19ff 100644 --- a/src/pages/AdminUsersPage.jsx +++ b/src/pages/AdminUsersPage.jsx @@ -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() { setQuery(event.target.value)}/> + )} {status === 'loading' && } @@ -149,10 +181,27 @@ export default function AdminUsersPage() {
{user.role !== 'USER' && {t(`ROLES.${user.role}`)}} {user.steamLinked && {t('ADMIN.STEAM')}} + {user.status === 'PENDING' && {t('ADMIN.PENDING')}} {user.blocked && {t('ADMIN.BLOCKED')}} - {user.deletedAt && {t('ADMIN.DELETED')}} + {user.deletedAt && ( + + {t(user.status === 'DENIED' ? 'ADMIN.DENIED' : 'ADMIN.DELETED')} + + )}
- {user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && ( + {user.status === 'PENDING' && ( +
+ + +
+ )} + {user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && (
- diff --git a/src/pages/AdminUsersPage.test.jsx b/src/pages/AdminUsersPage.test.jsx index 8cef8b5..21ba009 100644 --- a/src/pages/AdminUsersPage.test.jsx +++ b/src/pages/AdminUsersPage.test.jsx @@ -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(); diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index e8c0162..b648aa7 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -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() { diff --git a/src/pages/LoginPage.test.jsx b/src/pages/LoginPage.test.jsx index 915cd82..12896fa 100644 --- a/src/pages/LoginPage.test.jsx +++ b/src/pages/LoginPage.test.jsx @@ -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(); diff --git a/src/utils/admin.js b/src/utils/admin.js index 3120184..053125c 100644 --- a/src/utils/admin.js +++ b/src/utils/admin.js @@ -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`, {