From afe00ceae9ae788b4a02cb10177cd7cc2d3e7bb7 Mon Sep 17 00:00:00 2001 From: lobo Date: Sun, 27 Sep 2026 12:18:10 +0200 Subject: [PATCH] feat(admin): add the admin users page and view as --- README.md | 3 + public/i18n/de.json | 48 +++++- public/i18n/en.json | 48 +++++- src/App.jsx | 2 + src/components/AppHeader.jsx | 58 +++++++- src/components/AppHeader.test.jsx | 53 ++++++- src/hooks/useCurrentUser.js | 34 +++++ src/pages/AdminUsersPage.jsx | 235 ++++++++++++++++++++++++++++++ src/pages/AdminUsersPage.test.jsx | 97 ++++++++++++ src/pages/LoginPage.jsx | 1 + src/pages/LoginPage.test.jsx | 9 ++ src/utils/admin.js | 34 +++++ src/utils/admin.test.js | 25 ++++ src/utils/api.js | 10 ++ 14 files changed, 649 insertions(+), 8 deletions(-) create mode 100644 src/hooks/useCurrentUser.js create mode 100644 src/pages/AdminUsersPage.jsx create mode 100644 src/pages/AdminUsersPage.test.jsx create mode 100644 src/utils/admin.js create mode 100644 src/utils/admin.test.js diff --git a/README.md b/README.md index 7318ebe..98a4b03 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,9 @@ To Be Played is split into three repositories: 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`. - 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. - **Delete my account** in the settings: a dialog asks for the own email and the password. Auth blocks the account at once and removes it after 30 days; the page ends the session and goes to the landing page. - **Build version** in the settings: commit and build time of Frontend, Auth and Core, each hash links to its diff --git a/public/i18n/de.json b/public/i18n/de.json index 1e18dae..dd9eebe 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -15,7 +15,8 @@ "ERROR_INVALID_CREDENTIALS": "E-Mail oder Passwort ist falsch.", "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." + "SESSION_EXPIRED": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.", + "ERROR_ACCOUNT_BLOCKED": "Dieses Konto ist gesperrt. Bitte wende dich an den Admin." }, "REGISTRATION": { "TITLE": "Registrieren", @@ -277,7 +278,13 @@ "DARK_MODE": "Dunkles Design", "LIGHT_MODE": "Helles Design", "PLAY_NEXT": "Nächstes Spiel", - "STATS": "Statistik" + "STATS": "Statistik", + "ADMIN": "Admin", + "VIEW_AS": { + "MENU": "Ansehen als", + "BADGE": "Ansicht als {{role}}", + "ERROR": "Die Rolle konnte nicht gewechselt werden." + } }, "BACKLOG": { "TITLE": "Mein Backlog", @@ -474,5 +481,42 @@ "TURN_BASED_STRATEGY_TBS": "Rundenstrategie", "VISUAL_NOVEL": "Visual Novel" } + }, + "ROLES": { + "USER": "Nutzer", + "AUDITOR": "Prüfer", + "PLATFORM_ADMIN": "Admin" + }, + "ADMIN": { + "TITLE": "Nutzer", + "SEARCH": "E-Mail oder Name suchen", + "EMPTY": "Keine Nutzer gefunden.", + "NO_ACCESS": "Nur Admins können diese Seite öffnen.", + "LOAD_ERROR": "Die Nutzer konnten nicht geladen werden.", + "REGISTERED": "Registriert {{date}}", + "LAST_LOGIN": "Letzter Login {{date}}", + "NEVER": "nie", + "STEAM": "Steam", + "BLOCKED": "Gesperrt", + "DELETED": "Gelöscht", + "BLOCK": "Sperren", + "UNBLOCK": "Entsperren", + "DELETE": "Löschen", + "CANCEL": "Abbrechen", + "BLOCK_TITLE": "{{email}} sperren?", + "BLOCK_TEXT": "Der Nutzer kann sich nicht mehr anmelden; offene Sitzungen enden innerhalb von 15 Minuten. Du kannst später entsperren.", + "DELETE_TITLE": "{{email}} löschen?", + "DELETE_TEXT": "Das Konto wird sofort gesperrt und nach 30 Tagen mit allen Daten endgültig entfernt.", + "TYPE_EMAIL": "Gib zur Bestätigung die E-Mail ein", + "DONE_BLOCK": "Nutzer gesperrt.", + "DONE_UNBLOCK": "Nutzer entsperrt.", + "DONE_DELETE": "Nutzer gelöscht.", + "ERROR_SELF_ACTION": "Das geht nicht mit dem eigenen Konto.", + "ERROR_TARGET_IS_ADMIN": "Das geht nicht mit einem anderen Admin.", + "ERROR_ALREADY_DELETED": "Das Konto ist schon gelöscht.", + "ERROR": "Die Aktion ist fehlgeschlagen.", + "PAGE_OF": "Seite {{current}} von {{total}}", + "PREVIOUS": "Zurück", + "NEXT": "Weiter" } } diff --git a/public/i18n/en.json b/public/i18n/en.json index d590705..3d327e8 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -15,7 +15,8 @@ "ERROR_INVALID_CREDENTIALS": "Email or password is wrong.", "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." + "SESSION_EXPIRED": "Your session has expired. Please log in again.", + "ERROR_ACCOUNT_BLOCKED": "This account is blocked. Please contact the admin." }, "REGISTRATION": { "TITLE": "Register", @@ -277,7 +278,13 @@ "DARK_MODE": "Dark mode", "LIGHT_MODE": "Light mode", "PLAY_NEXT": "Play next", - "STATS": "Stats" + "STATS": "Stats", + "ADMIN": "Admin", + "VIEW_AS": { + "MENU": "View as", + "BADGE": "Viewing as {{role}}", + "ERROR": "The role could not be switched." + } }, "BACKLOG": { "TITLE": "My backlog", @@ -474,5 +481,42 @@ "TURN_BASED_STRATEGY_TBS": "Turn-based strategy", "VISUAL_NOVEL": "Visual novel" } + }, + "ROLES": { + "USER": "User", + "AUDITOR": "Auditor", + "PLATFORM_ADMIN": "Admin" + }, + "ADMIN": { + "TITLE": "Users", + "SEARCH": "Search email or name", + "EMPTY": "No users found.", + "NO_ACCESS": "Only admins can open this page.", + "LOAD_ERROR": "The users could not be loaded.", + "REGISTERED": "Registered {{date}}", + "LAST_LOGIN": "Last login {{date}}", + "NEVER": "never", + "STEAM": "Steam", + "BLOCKED": "Blocked", + "DELETED": "Deleted", + "BLOCK": "Block", + "UNBLOCK": "Unblock", + "DELETE": "Delete", + "CANCEL": "Cancel", + "BLOCK_TITLE": "Block {{email}}?", + "BLOCK_TEXT": "The user cannot log in any more; open sessions end within 15 minutes. You can unblock later.", + "DELETE_TITLE": "Delete {{email}}?", + "DELETE_TEXT": "The account is blocked at once and removed for good after 30 days, with all its data.", + "TYPE_EMAIL": "Type the email to confirm", + "DONE_BLOCK": "User blocked.", + "DONE_UNBLOCK": "User unblocked.", + "DONE_DELETE": "User deleted.", + "ERROR_SELF_ACTION": "You cannot do this to your own account.", + "ERROR_TARGET_IS_ADMIN": "You cannot do this to another admin.", + "ERROR_ALREADY_DELETED": "The account is already deleted.", + "ERROR": "The action failed.", + "PAGE_OF": "Page {{current}} of {{total}}", + "PREVIOUS": "Previous", + "NEXT": "Next" } } diff --git a/src/App.jsx b/src/App.jsx index a015e00..dbfe2ad 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,6 +8,7 @@ import ImprintPage from './pages/ImprintPage'; import Dashboard from "./pages/Dashboard.jsx"; import RegistrationPage from './pages/RegistrationPage.jsx'; import SettingsPage from './pages/SettingsPage.jsx'; +import AdminUsersPage from './pages/AdminUsersPage.jsx'; import GameLibrary from './pages/GameLibrary.jsx'; import GameDetail from './pages/GameDetail.jsx'; import WishlistPage from './pages/WishlistPage.jsx'; @@ -71,6 +72,7 @@ function App() { }/> }/> }/> + }/> }/> diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index 1c6e228..e295832 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -1,13 +1,18 @@ -import {Link, NavLink, useLocation} from 'react-router-dom'; +import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; -import {ChartColumn, Dices, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react'; +import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Shield, Sun} from 'lucide-react'; +import {toast} from 'sonner'; +import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import {usePreferences} from '@/context/PreferencesContext.jsx'; @@ -15,7 +20,11 @@ import {cn} from '@/lib/utils'; import Logo from './Logo.jsx'; import PageContainer from './PageContainer.jsx'; import SteamAvatar from './SteamAvatar.jsx'; +import useCurrentUser from '../hooks/useCurrentUser.js'; +import useSessionExpired from '../hooks/useSessionExpired.js'; import useSteamProfile from '../hooks/useSteamProfile.js'; +import {ROLES, viewAs} from '../utils/admin.js'; +import {markSession} from '../utils/auth.js'; const NAV_ITEMS = [ {to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard}, @@ -37,6 +46,30 @@ export default function AppHeader({isAuthenticated, onLogout}) { const {isDarkMode, setDarkMode, setLanguage} = usePreferences(); const {pathname} = useLocation(); const steamProfile = useSteamProfile(isAuthenticated); + const currentUser = useCurrentUser(isAuthenticated); + const navigate = useNavigate(); + const expireSession = useSessionExpired(); + const isAdmin = currentUser?.role === 'PLATFORM_ADMIN'; + const viewingAs = currentUser && currentUser.activeRole !== currentUser.role ? currentUser.activeRole : null; + + const switchRole = async (role) => { + try { + const response = await viewAs(role === currentUser.role ? null : role); + if (response.ok) { + // New cookies with the other role; the new stamp makes the header and pages load again. + markSession(); + navigate('/dashboard'); + return; + } + if (response.status === 401) { + expireSession(); + return; + } + } catch (err) { + console.error('View as error:', err); + } + toast.error(t('NAV.VIEW_AS.ERROR')); + }; const toggleLanguage = () => setLanguage(i18n.language === 'de' ? 'en' : 'de'); const toggleTheme = () => setDarkMode(!isDarkMode); @@ -64,6 +97,9 @@ export default function AppHeader({isAuthenticated, onLogout}) { )}
+ {isAuthenticated && viewingAs && ( + {t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})} + )} {isAuthenticated ? ( @@ -75,6 +111,24 @@ export default function AppHeader({isAuthenticated, onLogout}) { {t('NAV.SETTINGS')} + {isAdmin && !viewingAs && ( + + {t('NAV.ADMIN')} + + )} + {isAdmin && ( + + {t('NAV.VIEW_AS.MENU')} + + {ROLES.map((role) => ( + switchRole(role)}> + {t(`ROLES.${role}`)} + + ))} + + + )} {t('NAV.LANGUAGE')}: {i18n.language.toUpperCase()} diff --git a/src/components/AppHeader.test.jsx b/src/components/AppHeader.test.jsx index de2baa8..8395920 100644 --- a/src/components/AppHeader.test.jsx +++ b/src/components/AppHeader.test.jsx @@ -1,13 +1,30 @@ // @vitest-environment jsdom import {afterEach, describe, expect, it, vi} from 'vitest'; -import {cleanup, render, screen} from '@testing-library/react'; +import {cleanup, fireEvent, render, screen} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; import AppHeader from './AppHeader.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); -afterEach(cleanup); +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +const loggedInAs = (me) => { + localStorage.setItem('tbp_session', '1'); + globalThis.fetch = vi.fn(async (url) => ({ + ok: true, status: 200, json: async () => (url.includes('/api/users/me') ? me : {}), + })); + render( + + {}}/> + , + ); +}; + +const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'}); describe('AppHeader', () => { // jsdom applies no media queries, so the test hides the name the way the 380px breakpoint does. @@ -21,4 +38,36 @@ describe('AppHeader', () => { expect(screen.getByRole('link', {name: 'ToBePlayed'})).toBeTruthy(); }); + + it('offers the admin page and "view as" to an admin', async () => { + loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'}); + await screen.findByRole('button', {name: 'NAV.MENU'}); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything())); + + openMenu(); + + expect(await screen.findByText('NAV.ADMIN')).toBeTruthy(); + expect(screen.getByText('NAV.VIEW_AS.MENU')).toBeTruthy(); + expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull(); + }); + + it('shows the badge and hides the admin page while an admin views as a user', async () => { + loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'USER'}); + + expect(await screen.findByText('NAV.VIEW_AS.BADGE')).toBeTruthy(); + openMenu(); + expect(await screen.findByText('NAV.VIEW_AS.MENU')).toBeTruthy(); + expect(screen.queryByText('NAV.ADMIN')).toBeNull(); + }); + + it('offers nothing of that to a user', async () => { + loggedInAs({role: 'USER', activeRole: 'USER'}); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything())); + + openMenu(); + + expect(await screen.findByText('NAV.SETTINGS')).toBeTruthy(); + expect(screen.queryByText('NAV.ADMIN')).toBeNull(); + expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull(); + }); }); diff --git a/src/hooks/useCurrentUser.js b/src/hooks/useCurrentUser.js new file mode 100644 index 0000000..992a44d --- /dev/null +++ b/src/hooks/useCurrentUser.js @@ -0,0 +1,34 @@ +import {useEffect, useState} from 'react'; +import {apiFetch} from '../utils/api.js'; +import {sessionStamp} from '../utils/auth.js'; + +/** + * The stored role and the active role ("view as") of the logged-in user, or null while loading and on any error. + * Loads again after a new login or a role switch (see sessionStamp) on the next render. + */ +export default function useCurrentUser(enabled) { + const [user, setUser] = useState(null); + const stamp = sessionStamp(); + + useEffect(() => { + if (!enabled || !stamp) { + setUser(null); + return undefined; + } + let cancelled = false; + (async () => { + try { + const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`); + const data = response.ok ? await response.json() : null; + if (!cancelled) setUser(data && {role: data.role, activeRole: data.activeRole ?? data.role}); + } catch { + if (!cancelled) setUser(null); + } + })(); + return () => { + cancelled = true; + }; + }, [enabled, stamp]); + + return user; +} diff --git a/src/pages/AdminUsersPage.jsx b/src/pages/AdminUsersPage.jsx new file mode 100644 index 0000000..83f949f --- /dev/null +++ b/src/pages/AdminUsersPage.jsx @@ -0,0 +1,235 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; +import {useNavigate} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react'; +import {toast} from 'sonner'; + +import {Badge} from '@/components/ui/badge'; +import {Button} from '@/components/ui/button'; +import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import {Input} from '@/components/ui/input'; +import {Label} from '@/components/ui/label'; +import PageContainer from '../components/PageContainer.jsx'; +import useSessionExpired from '../hooks/useSessionExpired.js'; +import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js'; +import {hasSession} from '../utils/auth.js'; + +const PAGE_SIZE = 20; +const REFUSAL_KEY = { + SELF_ACTION: 'ADMIN.ERROR_SELF_ACTION', + TARGET_IS_ADMIN: 'ADMIN.ERROR_TARGET_IS_ADMIN', + ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED', +}; + +const formatDate = (value, locale) => (value ? new Date(value).toLocaleDateString(locale) : null); + +/** + * PLATFORM_ADMIN only: list, block and delete users. Auth checks the role; a 403 shows "no access". + */ +export default function AdminUsersPage() { + const {t, i18n} = useTranslation(); + const navigate = useNavigate(); + const expireSession = useSessionExpired(); + const [query, setQuery] = useState(''); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(0); + const [result, setResult] = useState(null); + const [status, setStatus] = useState('loading'); + // {kind: 'block' | 'delete', user} + const [pending, setPending] = useState(null); + const [typedEmail, setTypedEmail] = useState(''); + const [busy, setBusy] = useState(false); + // Only the newest load may show its answer: a slow answer to an older search must not replace the list. + const loadCount = useRef(0); + + const load = useCallback(async () => { + const current = ++loadCount.current; + try { + const response = await fetchUsers(search, page, PAGE_SIZE); + if (current !== loadCount.current) return; + if (response.status === 401) { + expireSession(); + return; + } + if (response.status === 403) { + setStatus('forbidden'); + return; + } + if (!response.ok) { + setStatus('error'); + return; + } + const data = await response.json(); + if (current !== loadCount.current) return; + setResult(data); + setStatus('ready'); + } catch (err) { + console.error('Admin users error:', err); + setStatus('error'); + } + }, [search, page, expireSession]); + + useEffect(() => { + if (!hasSession()) { + navigate('/login'); + return; + } + load(); + }, [load, navigate]); + + const closeDialog = () => { + setPending(null); + setTypedEmail(''); + }; + + const confirm = async () => { + const {kind, user} = pending; + setBusy(true); + try { + const response = kind === 'delete' ? await deleteUser(user.id) : await setUserBlocked(user.id, !user.blocked); + if (response.status === 401) { + expireSession(); + return; + } + if (response.ok) { + toast.success(t(kind === 'delete' ? 'ADMIN.DONE_DELETE' : user.blocked ? 'ADMIN.DONE_UNBLOCK' : 'ADMIN.DONE_BLOCK')); + closeDialog(); + await load(); + } else { + const data = await response.json().catch(() => ({})); + toast.error(t(REFUSAL_KEY[data.code] ?? 'ADMIN.ERROR')); + } + } catch (err) { + console.error('Admin action error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setBusy(false); + } + }; + + const submitSearch = (event) => { + event.preventDefault(); + setPage(0); + setSearch(query.trim()); + }; + + const confirmDisabled = busy || (pending?.kind === 'delete' + && typedEmail.trim().toLowerCase() !== pending.user.email.toLowerCase()); + + return ( + + + + {t('ADMIN.TITLE')} + + + {status === 'forbidden' &&

{t('ADMIN.NO_ACCESS')}

} + {status === 'error' &&

{t('ADMIN.LOAD_ERROR')}

} + {status !== 'forbidden' && ( +
+ setQuery(event.target.value)}/> + +
+ )} + {status === 'loading' && } + {status === 'ready' && result.content.length === 0 && ( +

{t('ADMIN.EMPTY')}

+ )} + {status === 'ready' && result.content.length > 0 && ( +
    + {result.content.map((user) => ( +
  • +
    +

    {user.email}

    +

    + {[user.displayName, + t('ADMIN.REGISTERED', {date: formatDate(user.registrationDate, i18n.language)}), + t('ADMIN.LAST_LOGIN', {date: formatDate(user.lastLoginDate, i18n.language) ?? t('ADMIN.NEVER')}), + ].filter(Boolean).join(' · ')} +

    +
    +
    + {user.role !== 'USER' && {t(`ROLES.${user.role}`)}} + {user.steamLinked && {t('ADMIN.STEAM')}} + {user.blocked && {t('ADMIN.BLOCKED')}} + {user.deletedAt && {t('ADMIN.DELETED')}} +
    + {user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && ( +
    + + +
    + )} +
  • + ))} +
+ )} + {status === 'ready' && result.totalPages > 1 && ( +
+ + {t('ADMIN.PAGE_OF', {current: page + 1, total: result.totalPages})} + +
+ )} +
+
+ + { if (!open) closeDialog(); }}> + {pending && ( + + + + {pending.kind === 'delete' + ? t('ADMIN.DELETE_TITLE', {email: pending.user.email}) + : pending.user.blocked ? t('ADMIN.UNBLOCK') : t('ADMIN.BLOCK_TITLE', {email: pending.user.email})} + + + {pending.kind === 'delete' ? t('ADMIN.DELETE_TEXT') : pending.user.blocked ? pending.user.email : t('ADMIN.BLOCK_TEXT')} + + + {pending.kind === 'delete' && ( +
+ + setTypedEmail(event.target.value)}/> +
+ )} + + + + + + +
+ )} +
+
+ ); +} diff --git a/src/pages/AdminUsersPage.test.jsx b/src/pages/AdminUsersPage.test.jsx new file mode 100644 index 0000000..9ef1cbf --- /dev/null +++ b/src/pages/AdminUsersPage.test.jsx @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import {toast} from 'sonner'; +import AdminUsersPage from './AdminUsersPage.jsx'; +import {PreferencesProvider} from '../context/PreferencesContext.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +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, +}; +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 answer = (status, body) => ({ok: status < 300, status, headers: new Headers(), json: async () => body}); + +const renderPage = () => render(); +const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method); + +describe('AdminUsersPage', () => { + beforeEach(() => { + localStorage.setItem('tbp_session', '1'); + globalThis.fetch = vi.fn(async (url, init) => (init?.method ? answer(204) : answer(200, page([ADMIN, PLAYER])))); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + vi.clearAllMocks(); + }); + + it('lists the users and offers no action on an admin', async () => { + renderPage(); + + expect(await screen.findByText('player@example.com')).toBeTruthy(); + expect(screen.getByText('admin@example.com')).toBeTruthy(); + expect(screen.getAllByRole('button', {name: 'ADMIN.BLOCK'})).toHaveLength(1); + expect(screen.getByText('ADMIN.STEAM')).toBeTruthy(); + }); + + it('searches by the typed text from the first page', async () => { + renderPage(); + await screen.findByText('player@example.com'); + + fireEvent.change(screen.getByPlaceholderText('ADMIN.SEARCH'), {target: {value: ' play '}}); + fireEvent.submit(screen.getByPlaceholderText('ADMIN.SEARCH').closest('form')); + + await waitFor(() => expect(calls('GET').at(-1)[0]).toContain('query=play&page=0')); + }); + + it('blocks a user after the confirm and loads the list again', async () => { + renderPage(); + fireEvent.click(await screen.findByRole('button', {name: 'ADMIN.BLOCK'})); + + fireEvent.click(screen.getAllByRole('button', {name: 'ADMIN.BLOCK'}).at(-1)); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_BLOCK')); + const [url, init] = calls('PUT')[0]; + expect(url).toMatch(/\/api\/admin\/users\/7\/blocked$/); + expect(JSON.parse(init.body)).toEqual({blocked: true}); + expect(calls('GET')).toHaveLength(2); + }); + + it('deletes only after the email is typed', async () => { + renderPage(); + fireEvent.click(await screen.findByRole('button', {name: 'ADMIN.DELETE'})); + const confirm = () => screen.getAllByRole('button', {name: 'ADMIN.DELETE'}).at(-1); + expect(confirm().disabled).toBe(true); + + fireEvent.change(screen.getByLabelText('ADMIN.TYPE_EMAIL'), {target: {value: 'PLAYER@example.com'}}); + fireEvent.click(confirm()); + + await waitFor(() => expect(calls('DELETE')[0][0]).toMatch(/\/api\/admin\/users\/7$/)); + }); + + it('shows the reason when Auth refuses', async () => { + fetch.mockImplementation(async (url, init) => (init?.method + ? answer(409, {code: 'TARGET_IS_ADMIN'}) : answer(200, page([PLAYER])))); + renderPage(); + fireEvent.click(await screen.findByRole('button', {name: 'ADMIN.BLOCK'})); + + fireEvent.click(screen.getAllByRole('button', {name: 'ADMIN.BLOCK'}).at(-1)); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_TARGET_IS_ADMIN')); + }); + + it('says no access to a user without the admin role', async () => { + fetch.mockResolvedValue(answer(403, {})); + renderPage(); + + expect(await screen.findByText('ADMIN.NO_ACCESS')).toBeTruthy(); + }); +}); diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index e2153bc..e8c0162 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -14,6 +14,7 @@ import {clientFetch} from '../utils/api.js'; const LOGIN_ERROR_KEY = { INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS', TOO_MANY_ATTEMPTS: 'LOGIN.ERROR_TOO_MANY_ATTEMPTS', + ACCOUNT_BLOCKED: 'LOGIN.ERROR_ACCOUNT_BLOCKED', }; export default function LoginPage() { diff --git a/src/pages/LoginPage.test.jsx b/src/pages/LoginPage.test.jsx index 2a34bad..74cb5bd 100644 --- a/src/pages/LoginPage.test.jsx +++ b/src/pages/LoginPage.test.jsx @@ -80,4 +80,13 @@ describe('LoginPage', () => { await waitFor(() => expect(screen.getByText('LOGIN.ERROR_INVALID_CREDENTIALS')).toBeTruthy()); expect(screen.queryByText('LOGIN.SESSION_EXPIRED')).toBeNull(); }); + + it('says that a blocked account is blocked', async () => { + fetch.mockResolvedValueOnce({ok: false, status: 403, headers: new Headers(), json: async () => ({code: 'ACCOUNT_BLOCKED'})}); + renderLogin(); + + submit(); + + expect(await screen.findByText('LOGIN.ERROR_ACCOUNT_BLOCKED')).toBeTruthy(); + }); }); diff --git a/src/utils/admin.js b/src/utils/admin.js new file mode 100644 index 0000000..f9fbc79 --- /dev/null +++ b/src/utils/admin.js @@ -0,0 +1,34 @@ +import {apiFetch, rotatingFetch} from './api.js'; + +const AUTH = () => import.meta.env.VITE_API_URL; + +export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN']; + +/** + * GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise). + */ +export const fetchUsers = (query, page, size) => + apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, page, size})}`); + +/** + * 204, or 409 with code SELF_ACTION, TARGET_IS_ADMIN or ALREADY_DELETED. + */ +export const setUserBlocked = (userId, blocked) => + apiFetch(`${AUTH()}/api/admin/users/${userId}/blocked`, { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({blocked}), + }); + +export const deleteUser = (userId) => apiFetch(`${AUTH()}/api/admin/users/${userId}`, {method: 'DELETE'}); + +/** + * "View as": new cookies that act with the role; null goes back to the own role. Under /api/auth, so it needs the + * refresh cookie and no access token; 403 ROLE_NOT_ALLOWED above the own role. + */ +export const viewAs = (role) => + rotatingFetch(`${AUTH()}/api/auth/view-as`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({role}), + }); diff --git a/src/utils/admin.test.js b/src/utils/admin.test.js new file mode 100644 index 0000000..db38343 --- /dev/null +++ b/src/utils/admin.test.js @@ -0,0 +1,25 @@ +// @vitest-environment jsdom +import {describe, expect, it, vi} from 'vitest'; +import {refreshSession} from './api.js'; +import {viewAs} from './admin.js'; + +describe('viewAs', () => { + it('waits for a running refresh before it sends the refresh cookie', async () => { + let finishRefresh; + globalThis.fetch = vi.fn((url) => (url.endsWith('/api/auth/refresh') + ? new Promise((resolve) => { + finishRefresh = () => resolve({ok: true, status: 204}); + }) + : Promise.resolve({ok: true, status: 204}))); + + const refreshing = refreshSession(); + const switching = viewAs('USER'); + await Promise.resolve(); + + expect(fetch.mock.calls.map(([url]) => url).filter((url) => url.endsWith('/api/auth/view-as'))).toHaveLength(0); + + finishRefresh(); + await refreshing; + await switching; + }); +}); diff --git a/src/utils/api.js b/src/utils/api.js index 198c97f..93d921c 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -44,6 +44,16 @@ export function refreshSession() { return runningRefresh; } +/** + * A request that rotates the refresh token like a refresh (e.g. "view as"). It waits for a running refresh and + * takes the same lock, so no two requests send one refresh token. + */ +export async function rotatingFetch(url, init = {}) { + await runningRefresh?.catch(() => undefined); + const send = () => clientFetch(url, init); + return globalThis.navigator?.locks ? navigator.locks.request('tbp-refresh', send) : send(); +} + /** * fetch for the logged-in user. A 401 renews the access cookie once and sends the request again. When that fails, * the 401 goes back to the caller. Ending the session is the caller's job (useSessionExpired), so the user sees