Public Access
Compare commits
2
Commits
b8f70e7578
...
79b086c525
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79b086c525 | ||
|
|
f4e69e1a94 |
No files matched your search
@@ -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
|
||||
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.
|
||||
- **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
|
||||
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.
|
||||
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).
|
||||
|
||||
+23
-8
@@ -16,23 +16,24 @@
|
||||
"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",
|
||||
"EMAIL": "Email",
|
||||
"PASSWORD": "Passwort",
|
||||
"PASSWORD_CONFIRMATION": "Passwort bestätigen",
|
||||
"INVITE_CODE": "Einladungscode",
|
||||
"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?",
|
||||
"ERROR_GENERIC": "Registrierung fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"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)",
|
||||
"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": {
|
||||
"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_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.",
|
||||
"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)."
|
||||
},
|
||||
"INFO": {
|
||||
@@ -296,7 +296,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 +534,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",
|
||||
|
||||
+23
-8
@@ -16,23 +16,24 @@
|
||||
"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",
|
||||
"EMAIL": "Email",
|
||||
"PASSWORD": "Password",
|
||||
"PASSWORD_CONFIRMATION": "Confirm password",
|
||||
"INVITE_CODE": "Invite code",
|
||||
"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?",
|
||||
"ERROR_GENERIC": "Registration failed. Please try again.",
|
||||
"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)",
|
||||
"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": {
|
||||
"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_CONFIRMATION_REQUIRED": "Please confirm your password.",
|
||||
"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)."
|
||||
},
|
||||
"INFO": {
|
||||
@@ -296,7 +296,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 +534,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",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Settings, Shield, Sun} from 'lucide-react';
|
||||
@@ -22,7 +23,7 @@ import PageContainer from './PageContainer.jsx';
|
||||
import SteamAvatar from './SteamAvatar.jsx';
|
||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||
import {ROLES, viewAs} from '../utils/admin.js';
|
||||
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
|
||||
import {expireSession, markSession} from '../utils/auth.js';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@@ -49,6 +50,24 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = currentUser?.role === 'PLATFORM_ADMIN';
|
||||
const viewingAs = currentUser && currentUser.activeRole !== currentUser.role ? currentUser.activeRole : null;
|
||||
const seesAdminPage = isAdmin && !viewingAs;
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [menuOpenings, setMenuOpenings] = useState(0);
|
||||
|
||||
// Loaded again on every page change and menu opening, so a decision on the admin page shows soon.
|
||||
useEffect(() => {
|
||||
if (!seesAdminPage) return undefined;
|
||||
let current = true;
|
||||
fetchPendingCount()
|
||||
.then(async (response) => {
|
||||
if (current && response.ok) setPendingCount((await response.json()).count);
|
||||
})
|
||||
.catch((err) => console.error('Pending count error:', err));
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [seesAdminPage, pathname, menuOpenings]);
|
||||
const shownCount = seesAdminPage ? pendingCount : 0;
|
||||
|
||||
const switchRole = async (role) => {
|
||||
try {
|
||||
@@ -108,20 +127,30 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
<Badge variant="secondary" className="mr-1">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge>
|
||||
)}
|
||||
{isAuthenticated ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu onOpenChange={(open) => open && setMenuOpenings((count) => count + 1)}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label={t('NAV.MENU')} title={steamProfile?.personaName ?? undefined}>
|
||||
<Button variant="ghost" size="icon" aria-label={t('NAV.MENU')} title={steamProfile?.personaName ?? undefined}
|
||||
className="relative">
|
||||
<SteamAvatar url={steamProfile?.avatarUrl}/>
|
||||
{shownCount > 0 && (
|
||||
<span aria-label={t('NAV.PENDING_REQUESTS', {count: shownCount})}
|
||||
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
||||
{shownCount > 9 ? '9+' : shownCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && !viewingAs && (
|
||||
{seesAdminPage && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin/users"><Shield/>{t('NAV.ADMIN')}</Link>
|
||||
<Link to="/admin/users">
|
||||
<Shield/>{t('NAV.ADMIN')}
|
||||
{shownCount > 0 && <Badge className="ml-auto">{shownCount}</Badge>}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin/hints"><Megaphone/>{t('NAV.ADMIN_HINTS')}</Link>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react';
|
||||
import {Ban, Check, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2, X} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
||||
import {decideUser, deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||
|
||||
@@ -27,21 +27,25 @@ const REFUSAL_KEY = {
|
||||
SELF_ACTION: 'ADMIN.ERROR_SELF_ACTION',
|
||||
TARGET_IS_ADMIN: 'ADMIN.ERROR_TARGET_IS_ADMIN',
|
||||
ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED',
|
||||
NOT_PENDING: 'ADMIN.ERROR_NOT_PENDING',
|
||||
TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING',
|
||||
};
|
||||
|
||||
const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null);
|
||||
|
||||
/**
|
||||
* PLATFORM_ADMIN only: list, block and delete users. Auth checks the role; a 403 shows "no access".
|
||||
* PLATFORM_ADMIN only: list, block and delete users, accept or deny registrations that wait. Auth checks the role; a
|
||||
* 403 shows "no access".
|
||||
*/
|
||||
export default function AdminUsersPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [pendingOnly, setPendingOnly] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [status, setStatus] = useState('loading');
|
||||
// {kind: 'block' | 'delete', user}
|
||||
// {kind: 'block' | 'delete' | 'accept' | 'deny', user}
|
||||
const [pending, setPending] = useState(null);
|
||||
const [typedEmail, setTypedEmail] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -51,7 +55,7 @@ export default function AdminUsersPage() {
|
||||
const load = useCallback(async () => {
|
||||
const signal = nextSignal();
|
||||
try {
|
||||
const response = await fetchUsers(search, page, PAGE_SIZE, {signal});
|
||||
const response = await fetchUsers(search, pendingOnly ? 'PENDING' : '', page, PAGE_SIZE, {signal});
|
||||
if (signal.aborted) return;
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 403) {
|
||||
@@ -71,7 +75,7 @@ export default function AdminUsersPage() {
|
||||
console.error('Admin users error:', err);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [nextSignal, search, page]);
|
||||
}, [nextSignal, search, pendingOnly, page]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -82,14 +86,35 @@ export default function AdminUsersPage() {
|
||||
setTypedEmail('');
|
||||
};
|
||||
|
||||
const email = pending?.user.email;
|
||||
const dialog = pending && {
|
||||
delete: {
|
||||
title: t('ADMIN.DELETE_TITLE', {email}), text: t('ADMIN.DELETE_TEXT'), action: 'ADMIN.DELETE',
|
||||
done: 'ADMIN.DONE_DELETE', destructive: true, run: () => deleteUser(pending.user.id),
|
||||
},
|
||||
block: {
|
||||
...(pending.user.blocked
|
||||
? {title: t('ADMIN.UNBLOCK'), text: email, action: 'ADMIN.UNBLOCK', done: 'ADMIN.DONE_UNBLOCK'}
|
||||
: {title: t('ADMIN.BLOCK_TITLE', {email}), text: t('ADMIN.BLOCK_TEXT'), action: 'ADMIN.BLOCK', done: 'ADMIN.DONE_BLOCK'}),
|
||||
run: () => setUserBlocked(pending.user.id, !pending.user.blocked),
|
||||
},
|
||||
accept: {
|
||||
title: t('ADMIN.ACCEPT_TITLE', {email}), text: t('ADMIN.ACCEPT_TEXT'), action: 'ADMIN.ACCEPT',
|
||||
done: 'ADMIN.DONE_ACCEPT', run: () => decideUser(pending.user.id, true),
|
||||
},
|
||||
deny: {
|
||||
title: t('ADMIN.DENY_TITLE', {email}), text: t('ADMIN.DENY_TEXT'), action: 'ADMIN.DENY',
|
||||
done: 'ADMIN.DONE_DENY', destructive: true, run: () => decideUser(pending.user.id, false),
|
||||
},
|
||||
}[pending.kind];
|
||||
|
||||
const confirm = async () => {
|
||||
const {kind, user} = pending;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = kind === 'delete' ? await deleteUser(user.id) : await setUserBlocked(user.id, !user.blocked);
|
||||
const response = await dialog.run();
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t(kind === 'delete' ? 'ADMIN.DONE_DELETE' : user.blocked ? 'ADMIN.DONE_UNBLOCK' : 'ADMIN.DONE_BLOCK'));
|
||||
toast.success(t(dialog.done));
|
||||
closeDialog();
|
||||
await load();
|
||||
} else {
|
||||
@@ -127,6 +152,13 @@ export default function AdminUsersPage() {
|
||||
<Input aria-label={t('ADMIN.SEARCH')} placeholder={t('ADMIN.SEARCH')} value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}/>
|
||||
<Button type="submit" variant="outline" size="icon" aria-label={t('ADMIN.SEARCH')}><Search/></Button>
|
||||
<Button type="button" variant={pendingOnly ? 'default' : 'outline'} aria-pressed={pendingOnly}
|
||||
onClick={() => {
|
||||
setPage(0);
|
||||
setPendingOnly(!pendingOnly);
|
||||
}}>
|
||||
{t('ADMIN.PENDING_ONLY')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
|
||||
@@ -149,10 +181,27 @@ export default function AdminUsersPage() {
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{user.role !== 'USER' && <Badge variant="outline">{t(`ROLES.${user.role}`)}</Badge>}
|
||||
{user.steamLinked && <Badge variant="secondary">{t('ADMIN.STEAM')}</Badge>}
|
||||
{user.status === 'PENDING' && <Badge>{t('ADMIN.PENDING')}</Badge>}
|
||||
{user.blocked && <Badge variant="destructive">{t('ADMIN.BLOCKED')}</Badge>}
|
||||
{user.deletedAt && <Badge variant="destructive">{t('ADMIN.DELETED')}</Badge>}
|
||||
{user.deletedAt && (
|
||||
<Badge variant="destructive">
|
||||
{t(user.status === 'DENIED' ? 'ADMIN.DENIED' : 'ADMIN.DELETED')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && (
|
||||
{user.status === 'PENDING' && (
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
onClick={() => setPending({kind: 'accept', user})}>
|
||||
<Check/>{t('ADMIN.ACCEPT')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" className="border-destructive text-destructive"
|
||||
onClick={() => setPending({kind: 'deny', user})}>
|
||||
<X/>{t('ADMIN.DENY')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && (
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
onClick={() => setPending({kind: 'block', user})}>
|
||||
@@ -189,14 +238,8 @@ export default function AdminUsersPage() {
|
||||
{pending && (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{pending.kind === 'delete'
|
||||
? t('ADMIN.DELETE_TITLE', {email: pending.user.email})
|
||||
: pending.user.blocked ? t('ADMIN.UNBLOCK') : t('ADMIN.BLOCK_TITLE', {email: pending.user.email})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pending.kind === 'delete' ? t('ADMIN.DELETE_TEXT') : pending.user.blocked ? pending.user.email : t('ADMIN.BLOCK_TEXT')}
|
||||
</DialogDescription>
|
||||
<DialogTitle>{dialog.title}</DialogTitle>
|
||||
<DialogDescription>{dialog.text}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{pending.kind === 'delete' && (
|
||||
<div className="space-y-2">
|
||||
@@ -209,10 +252,10 @@ export default function AdminUsersPage() {
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">{t('ADMIN.CANCEL')}</Button>
|
||||
</DialogClose>
|
||||
<Button type="button" variant={pending.kind === 'delete' ? 'destructive' : 'default'}
|
||||
<Button type="button" variant={dialog.destructive ? 'destructive' : 'default'}
|
||||
disabled={confirmDisabled} onClick={confirm}>
|
||||
{busy && <Loader2 className="animate-spin"/>}
|
||||
{t(pending.kind === 'delete' ? 'ADMIN.DELETE' : pending.user.blocked ? 'ADMIN.UNBLOCK' : 'ADMIN.BLOCK')}
|
||||
{t(dialog.action)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useState} from 'react';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
@@ -17,15 +17,16 @@ import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
|
||||
|
||||
const BACKEND_ERROR_KEY = {
|
||||
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',
|
||||
TOO_MANY_ATTEMPTS: 'REGISTRATION.ERROR_TOO_MANY_ATTEMPTS',
|
||||
};
|
||||
|
||||
export default function RegistrationPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
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 {
|
||||
register,
|
||||
@@ -37,7 +38,6 @@ export default function RegistrationPage() {
|
||||
email: '',
|
||||
password: '',
|
||||
passwordConfirmation: '',
|
||||
inviteCode: '',
|
||||
displayName: '',
|
||||
},
|
||||
});
|
||||
@@ -52,7 +52,6 @@ export default function RegistrationPage() {
|
||||
body: JSON.stringify({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
inviteCode: values.inviteCode,
|
||||
displayName: values.displayName,
|
||||
// Auth's mails use it: a German browser gets German, every other one English.
|
||||
language: navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en',
|
||||
@@ -61,7 +60,7 @@ export default function RegistrationPage() {
|
||||
|
||||
if (response.ok) {
|
||||
setFeedback({message: t('REGISTRATION.SUCCESS'), isError: false});
|
||||
setTimeout(() => navigate('/login'), 800);
|
||||
setRequested(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -90,7 +89,8 @@ export default function RegistrationPage() {
|
||||
</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}>
|
||||
<Input
|
||||
id="email"
|
||||
@@ -121,15 +121,6 @@ export default function RegistrationPage() {
|
||||
/>
|
||||
</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}>
|
||||
<Input
|
||||
id="displayName"
|
||||
@@ -144,7 +135,7 @@ export default function RegistrationPage() {
|
||||
{isSubmitting && <Loader2 className="animate-spin"/>}
|
||||
{t('REGISTRATION.SUBMIT')}
|
||||
</Button>
|
||||
</form>
|
||||
</form>}
|
||||
|
||||
<FeedbackAlert message={feedback.message} isError={feedback.isError}/>
|
||||
</AuthLayout>
|
||||
|
||||
@@ -3,7 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
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.
|
||||
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.PASSWORD'), {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'}));
|
||||
}
|
||||
|
||||
@@ -42,11 +41,11 @@ describe('RegistrationPage', () => {
|
||||
});
|
||||
|
||||
it('asks for no Steam ID and sends none', async () => {
|
||||
answerRegistration(400, 'INVALID_INVITATION_CODE');
|
||||
answerRegistration(409, 'EMAIL_ALREADY_IN_USE');
|
||||
|
||||
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(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']])(
|
||||
'sends the browser language %s as %s, for the mails', async (browser, sent) => {
|
||||
vi.spyOn(navigator, 'language', 'get').mockReturnValue(browser);
|
||||
answerRegistration(400, 'INVALID_INVITATION_CODE');
|
||||
answerRegistration(409, 'EMAIL_ALREADY_IN_USE');
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('maps a known backend error to its message', async () => {
|
||||
answerRegistration(400, 'INVALID_INVITATION_CODE');
|
||||
answerRegistration(409, 'EMAIL_ALREADY_IN_USE');
|
||||
|
||||
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 () => {
|
||||
|
||||
+17
-3
@@ -6,12 +6,26 @@ export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN'];
|
||||
|
||||
/**
|
||||
* GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise).
|
||||
*
|
||||
* @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`, {
|
||||
|
||||
@@ -13,10 +13,6 @@ const passwordField = z
|
||||
.min(8, 'VALIDATION.PASSWORD_TOO_SHORT')
|
||||
.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.
|
||||
// An empty field means "no display name" and is sent as null.
|
||||
const displayNameField = z
|
||||
@@ -33,7 +29,6 @@ export const registrationSchema = z
|
||||
email: emailField,
|
||||
password: passwordField,
|
||||
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
|
||||
inviteCode: inviteCodeField,
|
||||
displayName: displayNameField,
|
||||
})
|
||||
.refine((d) => d.password === d.passwordConfirmation, {
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('Steam ID', () => {
|
||||
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(registrationSchema.parse({
|
||||
email: 'a@b.de', password: 'password1', passwordConfirmation: 'password1', inviteCode: 'code',
|
||||
email: 'a@b.de', password: 'password1', passwordConfirmation: 'password1',
|
||||
steamId: '76561198005679788',
|
||||
})).not.toHaveProperty('steamId');
|
||||
});
|
||||
@@ -34,7 +34,7 @@ describe('registration password', () => {
|
||||
it('rejects a password over 72 UTF-8 bytes, as Auth does', () => {
|
||||
const password = 'é'.repeat(37);
|
||||
expect(registrationSchema.safeParse({
|
||||
email: 'a@b.de', password, passwordConfirmation: password, inviteCode: 'code',
|
||||
email: 'a@b.de', password, passwordConfirmation: password,
|
||||
}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user