Public Access
feat(password): add reset and change password pages
/reset-password page for the email request and the new password, ChangePasswordCard in settings, password reset button on the admin users page, 'Forgot password?' link on the login page, de/en texts.
This commit is contained in:
1 parent
f71e8f7aff
commit
6627a8db66
19 files changed
+565
-25
No files matched your search
@@ -102,6 +102,11 @@ To Be Played is split into three repositories:
|
||||
body); the page asks for the password (and its repeat) and confirms on a button click, not on load, so mail scanners do not burn the link. An expired or used
|
||||
link and the page after the send offer "send the link
|
||||
again" (same answer for any address).
|
||||
- **Forgot password** (`/reset-password`): the login page links to it. Without a fragment it asks for the email and
|
||||
shows the same text for any address. The mail link opens `/reset-password#<token>` (fragment, sent in a POST body on
|
||||
the button click only): the page asks for the new password twice. A used or expired link shows the email form again.
|
||||
**Change password** is a card in the settings (current password and the new one twice); the other logins end, this
|
||||
one stays. The admin users page has "Send password reset" for an active, unblocked user.
|
||||
- **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`.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"PASSWORD": "Passwort",
|
||||
"SUBMIT": "Spiel betreten",
|
||||
"NO_ACCOUNT": "Noch kein Konto?",
|
||||
"FORGOT_PASSWORD": "Passwort vergessen?",
|
||||
"ERROR_EMAIL": "Gib eine gültige Email-Adresse ein mit '@' und mit '.'.",
|
||||
"ERROR_PASSWORD": "Das Passwort darf nicht leer sein.",
|
||||
"ERROR_INVALID_CREDENTIALS": "E-Mail oder Passwort ist falsch.",
|
||||
@@ -38,6 +39,19 @@
|
||||
"INTRO": "Du bekommst eine Mail mit einem Link: dort bestätigst du deine Adresse und wählst dein Passwort. Danach prüft ein Admin dein Konto und du bekommst eine Mail mit der Entscheidung.",
|
||||
"ERROR_PENDING_LIMIT": "Gerade warten zu viele Registrierungen auf den Admin. Bitte versuch es später noch einmal."
|
||||
},
|
||||
"RESET_PASSWORD": {
|
||||
"TITLE": "Passwort zurücksetzen",
|
||||
"EMAIL": "E-Mail",
|
||||
"SEND": "Link zum Zurücksetzen senden",
|
||||
"SENT": "Wenn ein aktives Konto diese Adresse hat, ist ein Link unterwegs. Er ist 1 Stunde gültig.",
|
||||
"INTRO": "Wähle dein neues Passwort. Danach enden alle deine Anmeldungen.",
|
||||
"SUBMIT": "Passwort speichern",
|
||||
"DONE": "Dein Passwort ist geändert. Melde dich mit dem neuen an.",
|
||||
"TO_LOGIN": "Anmelden",
|
||||
"INVALID": "Dieser Link ist unbekannt, schon benutzt oder älter als 1 Stunde. Fordere einen neuen an:",
|
||||
"ERROR_TOO_MANY": "Zu viele Versuche. Bitte versuche es in {{minutes}} Minuten noch einmal.",
|
||||
"ERROR_GENERIC": "Das hat nicht geklappt. Bitte versuche es noch einmal."
|
||||
},
|
||||
"CONFIRM_EMAIL": {
|
||||
"TITLE": "E-Mail-Adresse bestätigen",
|
||||
"INTRO": "Wähle ein Passwort, um die Adresse deiner Registrierung zu bestätigen.",
|
||||
@@ -126,6 +140,17 @@
|
||||
},
|
||||
"VERSION": "Version",
|
||||
"VERSION_UNKNOWN": "unbekannt",
|
||||
"CHANGE_PASSWORD": {
|
||||
"TITLE": "Passwort ändern",
|
||||
"CURRENT": "Aktuelles Passwort",
|
||||
"NEW": "Neues Passwort",
|
||||
"REPEAT": "Neues Passwort wiederholen",
|
||||
"SUBMIT": "Passwort ändern",
|
||||
"DONE": "Passwort geändert. Deine anderen Anmeldungen sind beendet.",
|
||||
"WRONG_PASSWORD": "Das aktuelle Passwort ist falsch.",
|
||||
"TOO_MANY": "Zu viele falsche Passwörter. Versuche es in {{minutes}} Minuten noch einmal.",
|
||||
"ERROR": "Das Passwort konnte nicht geändert werden."
|
||||
},
|
||||
"DELETE_ACCOUNT": {
|
||||
"TITLE": "Konto löschen",
|
||||
"EXPLAIN": "Dein Konto wird sofort gesperrt: kein Login, kein öffentliches Profil. Nach 30 Tagen wird es endgültig entfernt, mit Bibliothek, Backlog und Statistiken.",
|
||||
@@ -151,6 +176,7 @@
|
||||
"PASSWORD_TOO_SHORT": "Passwort muss mindestens 8 Zeichen haben.",
|
||||
"PASSWORD_TOO_LONG": "Passwort ist zu lang: höchstens 72 Zeichen, mit Umlauten oder Emoji weniger.",
|
||||
"PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.",
|
||||
"CURRENT_PASSWORD_REQUIRED": "Gib dein aktuelles Passwort ein.",
|
||||
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.",
|
||||
"DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)."
|
||||
},
|
||||
@@ -559,6 +585,7 @@
|
||||
"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_NOT_ACTIVE": "Nur ein aktives Konto kann eine Reset-Mail bekommen.",
|
||||
"ERROR_ALREADY_DELETED": "Das Konto ist schon gelöscht.",
|
||||
"ERROR": "Die Aktion ist fehlgeschlagen.",
|
||||
"PAGE_OF": "Seite {{current}} von {{total}}",
|
||||
@@ -580,6 +607,10 @@
|
||||
"ERROR_TARGET_IS_UNCONFIRMED": "Die Adresse ist nicht bestätigt: lösche die Anfrage.",
|
||||
"ERROR_NOT_UNCONFIRMED": "Die Anfrage ist schon bestätigt.",
|
||||
"DONE_DENY": "Registrierung abgelehnt",
|
||||
"SEND_PASSWORD_RESET": "Passwort-Reset senden",
|
||||
"PASSWORD_RESET_TITLE": "Passwort-Reset an {{email}} senden?",
|
||||
"PASSWORD_RESET_TEXT": "Die Person bekommt eine Mail mit einem Link, um ein neues Passwort zu wählen (1 Stunde gültig). Du siehst das Passwort nie.",
|
||||
"DONE_PASSWORD_RESET": "Reset-Mail gesendet.",
|
||||
"ERROR_NOT_PENDING": "Diese Registrierung ist schon entschieden.",
|
||||
"ERROR_TARGET_IS_PENDING": "Nimm die Registrierung erst an oder lehne sie ab."
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"PASSWORD": "Password",
|
||||
"SUBMIT": "Enter the game",
|
||||
"NO_ACCOUNT": "No account yet?",
|
||||
"FORGOT_PASSWORD": "Forgot password?",
|
||||
"ERROR_EMAIL": "Enter a valid email address with '@' and with '.'.",
|
||||
"ERROR_PASSWORD": "The password must have at least one character.",
|
||||
"ERROR_INVALID_CREDENTIALS": "Email or password is wrong.",
|
||||
@@ -38,6 +39,19 @@
|
||||
"INTRO": "You get a mail with a link: there you confirm your address and choose your password. Then an admin checks your account and you get a mail with the decision.",
|
||||
"ERROR_PENDING_LIMIT": "Too many registrations wait for the admin right now. Please try again later."
|
||||
},
|
||||
"RESET_PASSWORD": {
|
||||
"TITLE": "Reset password",
|
||||
"EMAIL": "Email",
|
||||
"SEND": "Send reset link",
|
||||
"SENT": "If an active account has this address, a link is on its way. It is valid for 1 hour.",
|
||||
"INTRO": "Choose your new password. All your logins end after that.",
|
||||
"SUBMIT": "Save password",
|
||||
"DONE": "Your password is changed. Log in with the new one.",
|
||||
"TO_LOGIN": "Log in",
|
||||
"INVALID": "This link is unknown, already used or older than 1 hour. Request a new one:",
|
||||
"ERROR_TOO_MANY": "Too many attempts. Please try again in {{minutes}} minutes.",
|
||||
"ERROR_GENERIC": "That did not work. Please try again."
|
||||
},
|
||||
"CONFIRM_EMAIL": {
|
||||
"TITLE": "Confirm your email address",
|
||||
"INTRO": "Choose a password to confirm the address of your registration.",
|
||||
@@ -126,6 +140,17 @@
|
||||
},
|
||||
"VERSION": "Version",
|
||||
"VERSION_UNKNOWN": "unknown",
|
||||
"CHANGE_PASSWORD": {
|
||||
"TITLE": "Change password",
|
||||
"CURRENT": "Current password",
|
||||
"NEW": "New password",
|
||||
"REPEAT": "Repeat new password",
|
||||
"SUBMIT": "Change password",
|
||||
"DONE": "Password changed. Your other logins are ended.",
|
||||
"WRONG_PASSWORD": "The current password is wrong.",
|
||||
"TOO_MANY": "Too many wrong passwords. Try again in {{minutes}} minutes.",
|
||||
"ERROR": "The password could not be changed."
|
||||
},
|
||||
"DELETE_ACCOUNT": {
|
||||
"TITLE": "Delete account",
|
||||
"EXPLAIN": "Your account is blocked at once: no login, no public profile. After 30 days it is removed for good, with your library, backlog and stats.",
|
||||
@@ -151,6 +176,7 @@
|
||||
"PASSWORD_TOO_SHORT": "Password must be at least 8 characters.",
|
||||
"PASSWORD_TOO_LONG": "Password is too long: at most 72 characters, fewer with umlauts or emoji.",
|
||||
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
|
||||
"CURRENT_PASSWORD_REQUIRED": "Enter your current password.",
|
||||
"PASSWORD_MISMATCH": "Passwords do not match.",
|
||||
"DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)."
|
||||
},
|
||||
@@ -559,6 +585,7 @@
|
||||
"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_NOT_ACTIVE": "Only an active account can get a reset mail.",
|
||||
"ERROR_ALREADY_DELETED": "The account is already deleted.",
|
||||
"ERROR": "The action failed.",
|
||||
"PAGE_OF": "Page {{current}} of {{total}}",
|
||||
@@ -580,6 +607,10 @@
|
||||
"ERROR_TARGET_IS_UNCONFIRMED": "The address is not confirmed: delete the request.",
|
||||
"ERROR_NOT_UNCONFIRMED": "The request is confirmed already.",
|
||||
"DONE_DENY": "Registration denied",
|
||||
"SEND_PASSWORD_RESET": "Send password reset",
|
||||
"PASSWORD_RESET_TITLE": "Send a password reset to {{email}}?",
|
||||
"PASSWORD_RESET_TEXT": "The user gets a mail with a link to choose a new password (valid 1 hour). You never see the password.",
|
||||
"DONE_PASSWORD_RESET": "Reset mail sent.",
|
||||
"ERROR_NOT_PENDING": "This registration is decided already.",
|
||||
"ERROR_TARGET_IS_PENDING": "Accept or deny the registration first."
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import ImprintPage from './pages/ImprintPage';
|
||||
import Dashboard from "./pages/Dashboard.jsx";
|
||||
import RegistrationPage from './pages/RegistrationPage.jsx';
|
||||
import ConfirmEmailPage from './pages/ConfirmEmailPage.jsx';
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage.jsx';
|
||||
import SettingsPage from './pages/SettingsPage.jsx';
|
||||
import AdminHintsPage from './pages/AdminHintsPage.jsx';
|
||||
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
|
||||
@@ -75,6 +76,7 @@ function App() {
|
||||
<Route path="/login" element={<LoginPage/>}/>
|
||||
<Route path="/register" element={<RegistrationPage/>}/>
|
||||
<Route path="/confirm-email" element={<ConfirmEmailPage/>}/>
|
||||
<Route path="/reset-password" element={<ResetPasswordPage/>}/>
|
||||
<Route element={<RequireSession/>}>
|
||||
<Route path="/dashboard" element={<WelcomeGate><Dashboard/></WelcomeGate>}/>
|
||||
<Route path="/library" element={<GameLibrary/>}/>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {KeyRound, Loader2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import FormField from './FormField.jsx';
|
||||
import {changePassword} from '../utils/account.js';
|
||||
import {changePasswordSchema} from '../utils/validation.js';
|
||||
|
||||
const EMPTY = {currentPassword: '', password: '', passwordConfirmation: ''};
|
||||
|
||||
/**
|
||||
* "Change password": Auth ends every other login and keeps this one.
|
||||
*/
|
||||
export default function ChangePasswordCard() {
|
||||
const {t} = useTranslation();
|
||||
const {register, handleSubmit, reset, formState: {errors, isSubmitting}} = useForm({
|
||||
resolver: zodResolver(changePasswordSchema),
|
||||
defaultValues: EMPTY,
|
||||
});
|
||||
|
||||
const submit = async ({currentPassword, password}) => {
|
||||
try {
|
||||
const response = await changePassword(currentPassword, password);
|
||||
if (response.status === 204) {
|
||||
toast.success(t('SETTINGS.CHANGE_PASSWORD.DONE'));
|
||||
reset(EMPTY);
|
||||
} else if (response.status === 403) {
|
||||
toast.error(t('SETTINGS.CHANGE_PASSWORD.WRONG_PASSWORD'));
|
||||
} else if (response.status === 429) {
|
||||
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
||||
toast.error(t('SETTINGS.CHANGE_PASSWORD.TOO_MANY', {minutes}));
|
||||
} else if (response.status !== 401) {
|
||||
toast.error(t('SETTINGS.CHANGE_PASSWORD.ERROR'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Change password error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><KeyRound className="size-4"/>{t('SETTINGS.CHANGE_PASSWORD.TITLE')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(submit)} className="space-y-4" noValidate>
|
||||
<FormField id="currentPassword" label={t('SETTINGS.CHANGE_PASSWORD.CURRENT')} error={errors.currentPassword} t={t}>
|
||||
<Input id="currentPassword" type="password" autoComplete="current-password"
|
||||
aria-invalid={!!errors.currentPassword} {...register('currentPassword')}/>
|
||||
</FormField>
|
||||
<FormField id="newPassword" label={t('SETTINGS.CHANGE_PASSWORD.NEW')} error={errors.password} t={t}>
|
||||
<Input id="newPassword" type="password" autoComplete="new-password"
|
||||
aria-invalid={!!errors.password} {...register('password')}/>
|
||||
</FormField>
|
||||
<FormField id="newPasswordRepeat" label={t('SETTINGS.CHANGE_PASSWORD.REPEAT')} error={errors.passwordConfirmation} t={t}>
|
||||
<Input id="newPasswordRepeat" type="password" autoComplete="new-password"
|
||||
aria-invalid={!!errors.passwordConfirmation} {...register('passwordConfirmation')}/>
|
||||
</FormField>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="animate-spin"/>}
|
||||
{t('SETTINGS.CHANGE_PASSWORD.SUBMIT')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {toast} from 'sonner';
|
||||
import ChangePasswordCard from './ChangePasswordCard.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
|
||||
const answers = (answer) => fetchRoutes({'POST /api/auth/password': answer});
|
||||
|
||||
function fillAndSubmit(current = 'old password', next = 'new password 1', repeat = next) {
|
||||
fireEvent.change(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.CURRENT'), {target: {value: current}});
|
||||
fireEvent.change(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.NEW'), {target: {value: next}});
|
||||
fireEvent.change(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.REPEAT'), {target: {value: repeat}});
|
||||
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.CHANGE_PASSWORD.SUBMIT'}));
|
||||
}
|
||||
|
||||
describe('ChangePasswordCard', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sends the current and the new password and clears the form on success', async () => {
|
||||
const mock = answers(json(204));
|
||||
render(<ChangePasswordCard/>);
|
||||
|
||||
fillAndSubmit();
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('SETTINGS.CHANGE_PASSWORD.DONE'));
|
||||
expect(mock.mock.calls[0][0]).toMatch(/\/api\/auth\/password$/);
|
||||
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({currentPassword: 'old password', newPassword: 'new password 1'});
|
||||
expect(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.CURRENT').value).toBe('');
|
||||
});
|
||||
|
||||
it('tells a wrong current password', async () => {
|
||||
answers(json(403, {code: 'INVALID_PASSWORD'}));
|
||||
render(<ChangePasswordCard/>);
|
||||
|
||||
fillAndSubmit();
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.CHANGE_PASSWORD.WRONG_PASSWORD'));
|
||||
});
|
||||
|
||||
it('tells the lock with the minutes', async () => {
|
||||
answers({...json(429, {}), headers: new Headers({'Retry-After': '900'})});
|
||||
render(<ChangePasswordCard/>);
|
||||
|
||||
fillAndSubmit();
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.CHANGE_PASSWORD.TOO_MANY'));
|
||||
});
|
||||
|
||||
it('sends nothing when the repeat differs or the current password is empty', async () => {
|
||||
const mock = fetchRoutes({});
|
||||
render(<ChangePasswordCard/>);
|
||||
|
||||
fillAndSubmit('old password', 'new password 1', 'new password 2');
|
||||
expect(await screen.findByText('VALIDATION.PASSWORD_MISMATCH')).toBeTruthy();
|
||||
fillAndSubmit('', 'new password 1');
|
||||
expect(await screen.findByText('VALIDATION.CURRENT_PASSWORD_REQUIRED')).toBeTruthy();
|
||||
|
||||
expect(mock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import {Input} from '@/components/ui/input';
|
||||
import FormField from './FormField.jsx';
|
||||
|
||||
/**
|
||||
* The new password and its repeat, for a form on confirmEmailSchema.
|
||||
*/
|
||||
export default function NewPasswordFields({register, errors, t}) {
|
||||
return <>
|
||||
<FormField id="password" label={t('REGISTRATION.PASSWORD')} error={errors.password} t={t}>
|
||||
<Input id="password" type="password" autoComplete="new-password" aria-invalid={!!errors.password}
|
||||
{...register('password')}/>
|
||||
</FormField>
|
||||
<FormField id="passwordConfirmation" label={t('REGISTRATION.PASSWORD_CONFIRMATION')}
|
||||
error={errors.passwordConfirmation} t={t}>
|
||||
<Input id="passwordConfirmation" type="password" autoComplete="new-password"
|
||||
aria-invalid={!!errors.passwordConfirmation} {...register('passwordConfirmation')}/>
|
||||
</FormField>
|
||||
</>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Ban, Check, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2, X} from 'lucide-react';
|
||||
import {Ban, Check, ChevronLeft, ChevronRight, KeyRound, 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 {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
||||
import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, sendPasswordReset, setUserBlocked} from '../utils/admin.js';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||
|
||||
@@ -31,6 +31,7 @@ const REFUSAL_KEY = {
|
||||
TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING',
|
||||
TARGET_IS_UNCONFIRMED: 'ADMIN.ERROR_TARGET_IS_UNCONFIRMED',
|
||||
NOT_UNCONFIRMED: 'ADMIN.ERROR_NOT_UNCONFIRMED',
|
||||
NOT_ACTIVE: 'ADMIN.ERROR_NOT_ACTIVE',
|
||||
};
|
||||
|
||||
const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null);
|
||||
@@ -47,7 +48,7 @@ export default function AdminUsersPage() {
|
||||
const [pendingOnly, setPendingOnly] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [status, setStatus] = useState('loading');
|
||||
// {kind: 'block' | 'delete' | 'deleteUnconfirmed' | 'accept' | 'deny', user}
|
||||
// {kind: 'block' | 'delete' | 'deleteUnconfirmed' | 'accept' | 'deny' | 'passwordReset', user}
|
||||
const [pending, setPending] = useState(null);
|
||||
const [typedEmail, setTypedEmail] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -105,6 +106,11 @@ export default function AdminUsersPage() {
|
||||
: {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),
|
||||
},
|
||||
passwordReset: {
|
||||
title: t('ADMIN.PASSWORD_RESET_TITLE', {email}), text: t('ADMIN.PASSWORD_RESET_TEXT'),
|
||||
action: 'ADMIN.SEND_PASSWORD_RESET', done: 'ADMIN.DONE_PASSWORD_RESET',
|
||||
run: () => sendPasswordReset(pending.user.id),
|
||||
},
|
||||
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),
|
||||
@@ -217,6 +223,12 @@ export default function AdminUsersPage() {
|
||||
)}
|
||||
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && user.status !== 'UNCONFIRMED' && (
|
||||
<div className="flex gap-2">
|
||||
{user.status === 'ACTIVE' && !user.blocked && (
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
onClick={() => setPending({kind: 'passwordReset', user})}>
|
||||
<KeyRound/>{t('ADMIN.SEND_PASSWORD_RESET')}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
onClick={() => setPending({kind: 'block', user})}>
|
||||
{user.blocked ? <ShieldCheck/> : <Ban/>}
|
||||
|
||||
@@ -87,6 +87,35 @@ describe('AdminUsersPage', () => {
|
||||
await waitFor(() => expect(calls('DELETE')[0][0]).toMatch(/\/api\/admin\/users\/7$/));
|
||||
});
|
||||
|
||||
it('offers the password reset only for an active, unblocked non-admin and posts it after the confirm', async () => {
|
||||
withRoutes({
|
||||
'GET /api/admin/users': json(200, page([ADMIN, PLAYER, NEWCOMER, {...PLAYER, id: 11, email: 'b@example.com', blocked: true}])),
|
||||
'POST /api/admin/users/7/password-reset': json(204),
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText('player@example.com');
|
||||
expect(screen.getAllByRole('button', {name: 'ADMIN.SEND_PASSWORD_RESET'})).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.SEND_PASSWORD_RESET'}));
|
||||
fireEvent.click(screen.getAllByRole('button', {name: 'ADMIN.SEND_PASSWORD_RESET'}).at(-1));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_PASSWORD_RESET'));
|
||||
expect(calls('POST')[0][0]).toMatch(/\/api\/admin\/users\/7\/password-reset$/);
|
||||
});
|
||||
|
||||
it('shows the reason when the user is not active any more', async () => {
|
||||
withRoutes({
|
||||
'GET /api/admin/users': json(200, page([PLAYER])),
|
||||
'POST /api/admin/users/7/password-reset': json(409, {code: 'NOT_ACTIVE'}),
|
||||
});
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'ADMIN.SEND_PASSWORD_RESET'}));
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', {name: 'ADMIN.SEND_PASSWORD_RESET'}).at(-1));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_NOT_ACTIVE'));
|
||||
});
|
||||
|
||||
it('shows the reason when Auth refuses', async () => {
|
||||
withRoutes({
|
||||
'GET /api/admin/users': json(200, page([PLAYER])),
|
||||
|
||||
@@ -6,10 +6,9 @@ import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {Loader2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import AuthLayout from '../components/AuthLayout.jsx';
|
||||
import FeedbackAlert from '../components/FeedbackAlert.jsx';
|
||||
import FormField from '../components/FormField.jsx';
|
||||
import NewPasswordFields from '../components/NewPasswordFields.jsx';
|
||||
import ResendConfirmation from '../components/ResendConfirmation.jsx';
|
||||
import {confirmEmailSchema} from '../utils/validation.js';
|
||||
import {clientFetch} from '../utils/api.js';
|
||||
@@ -61,24 +60,7 @@ function ConfirmEmail({token}) {
|
||||
<AuthLayout title={t('CONFIRM_EMAIL.TITLE')}>
|
||||
{state === 'idle' && <form onSubmit={handleSubmit(confirm)} className="space-y-4" noValidate>
|
||||
<p className="text-sm text-muted-foreground">{t('CONFIRM_EMAIL.INTRO')}</p>
|
||||
<FormField id="password" label={t('REGISTRATION.PASSWORD')} error={errors.password} t={t}>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={!!errors.password}
|
||||
{...register('password')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField id="passwordConfirmation" label={t('REGISTRATION.PASSWORD_CONFIRMATION')} error={errors.passwordConfirmation} t={t}>
|
||||
<Input
|
||||
id="passwordConfirmation"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={!!errors.passwordConfirmation}
|
||||
{...register('passwordConfirmation')}
|
||||
/>
|
||||
</FormField>
|
||||
<NewPasswordFields register={register} errors={errors} t={t}/>
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||
{isSubmitting && <Loader2 className="animate-spin"/>}
|
||||
{t('CONFIRM_EMAIL.SUBMIT')}
|
||||
|
||||
@@ -116,6 +116,9 @@ export default function LoginPage() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Link to="/reset-password" className="block text-sm text-muted-foreground hover:underline">
|
||||
{t('LOGIN.FORGOT_PASSWORD')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isLoading} className="w-full">
|
||||
|
||||
@@ -48,6 +48,12 @@ describe('LoginPage', () => {
|
||||
expect(screen.getByText('LOGIN.SESSION_EXPIRED')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('links to the forgot password page', () => {
|
||||
renderLogin(undefined);
|
||||
|
||||
expect(screen.getByRole('link', {name: 'LOGIN.FORGOT_PASSWORD'}).getAttribute('href')).toBe('/reset-password');
|
||||
});
|
||||
|
||||
it('shows no message on a normal visit', () => {
|
||||
renderLogin(undefined);
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import {useState} from 'react';
|
||||
import {Link, useLocation} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {Loader2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import AuthLayout from '../components/AuthLayout.jsx';
|
||||
import FeedbackAlert from '../components/FeedbackAlert.jsx';
|
||||
import FormField from '../components/FormField.jsx';
|
||||
import NewPasswordFields from '../components/NewPasswordFields.jsx';
|
||||
import {confirmEmailSchema} from '../utils/validation.js';
|
||||
import {clientFetch} from '../utils/api.js';
|
||||
|
||||
const post = (path, body) => clientFetch(`${import.meta.env.VITE_API_URL}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const failureMessage = (t, response) => {
|
||||
if (response.status === 429) {
|
||||
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
||||
return t('RESET_PASSWORD.ERROR_TOO_MANY', {minutes});
|
||||
}
|
||||
return t('RESET_PASSWORD.ERROR_GENERIC');
|
||||
};
|
||||
|
||||
/**
|
||||
* Both ends of "forgot password". Without a fragment: ask for the mail. With the fragment of the mail link: choose
|
||||
* the new password. The token is in the fragment, so no server log sees it, and it is sent only on the click: a mail
|
||||
* scanner that opens the link must not use it up.
|
||||
*/
|
||||
export default function ResetPasswordPage() {
|
||||
const token = useLocation().hash.slice(1);
|
||||
// A new link in the same tab only changes the fragment: key restarts the state.
|
||||
return <ResetPassword key={token} token={token}/>;
|
||||
}
|
||||
|
||||
function ResetPassword({token}) {
|
||||
const {t} = useTranslation();
|
||||
const [state, setState] = useState(token ? 'idle' : 'request');
|
||||
|
||||
return (
|
||||
<AuthLayout title={t('RESET_PASSWORD.TITLE')}>
|
||||
{state === 'idle' && (
|
||||
<NewPasswordForm token={token} onDone={() => setState('done')} onInvalid={() => setState('invalid')}/>
|
||||
)}
|
||||
{state === 'done' && <>
|
||||
<FeedbackAlert message={t('RESET_PASSWORD.DONE')} isError={false}/>
|
||||
<Link to="/login" className="block text-center text-sm font-medium text-primary hover:underline">
|
||||
{t('RESET_PASSWORD.TO_LOGIN')}
|
||||
</Link>
|
||||
</>}
|
||||
{state === 'invalid' && <FeedbackAlert message={t('RESET_PASSWORD.INVALID')} isError/>}
|
||||
{(state === 'request' || state === 'invalid') && <RequestForm/>}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestForm() {
|
||||
const {t} = useTranslation();
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [feedback, setFeedback] = useState({message: '', isError: false});
|
||||
|
||||
const send = async (event) => {
|
||||
event.preventDefault();
|
||||
setIsLoading(true);
|
||||
setFeedback({message: '', isError: false});
|
||||
try {
|
||||
const response = await post('/api/auth/password-reset', {email});
|
||||
setFeedback(response.ok
|
||||
? {message: t('RESET_PASSWORD.SENT'), isError: false}
|
||||
: {message: failureMessage(t, response), isError: true});
|
||||
} catch (error) {
|
||||
console.error('Password reset API error:', error);
|
||||
setFeedback({message: t('INFO.ERROR.NETWORK_FAILURE'), isError: true});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={send} className="space-y-3" noValidate>
|
||||
<FormField id="reset-email" label={t('RESET_PASSWORD.EMAIL')} t={t}>
|
||||
<Input id="reset-email" type="email" autoComplete="email" value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}/>
|
||||
</FormField>
|
||||
<Button type="submit" disabled={isLoading} className="w-full">
|
||||
{isLoading && <Loader2 className="animate-spin"/>}
|
||||
{t('RESET_PASSWORD.SEND')}
|
||||
</Button>
|
||||
<FeedbackAlert message={feedback.message} isError={feedback.isError}/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function NewPasswordForm({token, onDone, onInvalid}) {
|
||||
const {t} = useTranslation();
|
||||
const [error, setError] = useState('');
|
||||
const {register, handleSubmit, formState: {errors, isSubmitting}} = useForm({
|
||||
resolver: zodResolver(confirmEmailSchema),
|
||||
defaultValues: {password: '', passwordConfirmation: ''},
|
||||
});
|
||||
|
||||
const submit = async ({password}) => {
|
||||
setError('');
|
||||
try {
|
||||
const response = await post('/api/auth/password-reset/confirm', {token, password});
|
||||
if (response.ok) {
|
||||
onDone();
|
||||
} else if (response.status === 410 || response.status === 400) {
|
||||
onInvalid();
|
||||
} else {
|
||||
setError(failureMessage(t, response));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Password reset confirm error:', err);
|
||||
setError(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(submit)} className="space-y-4" noValidate>
|
||||
<p className="text-sm text-muted-foreground">{t('RESET_PASSWORD.INTRO')}</p>
|
||||
<NewPasswordFields register={register} errors={errors} t={t}/>
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||
{isSubmitting && <Loader2 className="animate-spin"/>}
|
||||
{t('RESET_PASSWORD.SUBMIT')}
|
||||
</Button>
|
||||
<FeedbackAlert message={error} isError/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import ResetPasswordPage from './ResetPasswordPage.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
const renderPage = (hash = '') => render(
|
||||
<MemoryRouter initialEntries={[`/reset-password${hash}`]}><ResetPasswordPage/></MemoryRouter>,
|
||||
);
|
||||
|
||||
function submitPasswords(password, repeat = password) {
|
||||
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD'), {target: {value: password}});
|
||||
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD_CONFIRMATION'), {target: {value: repeat}});
|
||||
fireEvent.click(screen.getByRole('button', {name: 'RESET_PASSWORD.SUBMIT'}));
|
||||
}
|
||||
|
||||
function submitEmail(email = 'a@b.de') {
|
||||
fireEvent.change(screen.getByLabelText('RESET_PASSWORD.EMAIL'), {target: {value: email}});
|
||||
fireEvent.click(screen.getByRole('button', {name: 'RESET_PASSWORD.SEND'}));
|
||||
}
|
||||
|
||||
describe('ResetPasswordPage', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('without a fragment asks for the email and shows the same text for any address', async () => {
|
||||
const mock = fetchRoutes({'POST /api/auth/password-reset': json(204, null)});
|
||||
renderPage();
|
||||
|
||||
submitEmail();
|
||||
|
||||
expect(await screen.findByText('RESET_PASSWORD.SENT')).toBeTruthy();
|
||||
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({email: 'a@b.de'});
|
||||
});
|
||||
|
||||
it('shows the lock text with the minutes on a 429', async () => {
|
||||
fetchRoutes({'POST /api/auth/password-reset': {...json(429, {}), headers: new Headers({'Retry-After': '900'})}});
|
||||
renderPage();
|
||||
|
||||
submitEmail();
|
||||
|
||||
expect(await screen.findByText('RESET_PASSWORD.ERROR_TOO_MANY')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('with a fragment sends the token and the new password only on the click and then offers the login', async () => {
|
||||
const mock = fetchRoutes({'POST /api/auth/password-reset/confirm': json(204, null)});
|
||||
renderPage('#tok-123');
|
||||
|
||||
expect(mock).not.toHaveBeenCalled();
|
||||
submitPasswords('secret123');
|
||||
|
||||
expect(await screen.findByText('RESET_PASSWORD.DONE')).toBeTruthy();
|
||||
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({token: 'tok-123', password: 'secret123'});
|
||||
expect(screen.getByRole('link', {name: 'RESET_PASSWORD.TO_LOGIN'}).getAttribute('href')).toBe('/login');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a short password', 'short', 'short', 'VALIDATION.PASSWORD_TOO_SHORT'],
|
||||
['a different repeat', 'secret123', 'secret124', 'VALIDATION.PASSWORD_MISMATCH'],
|
||||
])('sends nothing for %s', async (_name, password, repeat, message) => {
|
||||
const mock = fetchRoutes({});
|
||||
renderPage('#tok-123');
|
||||
|
||||
submitPasswords(password, repeat);
|
||||
|
||||
expect(await screen.findByText(message)).toBeTruthy();
|
||||
expect(mock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the invalid text and the email form again for a used or expired link', async () => {
|
||||
fetchRoutes({'POST /api/auth/password-reset/confirm': json(410, {code: 'RESET_INVALID'})});
|
||||
renderPage('#old');
|
||||
|
||||
submitPasswords('secret123');
|
||||
|
||||
expect(await screen.findByText('RESET_PASSWORD.INVALID')).toBeTruthy();
|
||||
expect(screen.getByRole('button', {name: 'RESET_PASSWORD.SEND'})).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the lock text with the minutes when the confirm is limited', async () => {
|
||||
fetchRoutes({
|
||||
'POST /api/auth/password-reset/confirm': {...json(429, {}), headers: new Headers({'Retry-After': '900'})},
|
||||
});
|
||||
renderPage('#tok');
|
||||
|
||||
submitPasswords('secret123');
|
||||
|
||||
expect(await screen.findByText('RESET_PASSWORD.ERROR_TOO_MANY')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
||||
import AccountInfoCard from '../components/AccountInfoCard.jsx';
|
||||
import AppearanceCard from '../components/AppearanceCard.jsx';
|
||||
import BuildVersionCard from '../components/BuildVersionCard.jsx';
|
||||
import ChangePasswordCard from '../components/ChangePasswordCard.jsx';
|
||||
import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
|
||||
import PreferencesCard from '../components/PreferencesCard.jsx';
|
||||
import ProfileCard from '../components/ProfileCard.jsx';
|
||||
@@ -95,6 +96,8 @@ export default function SettingsPage() {
|
||||
|
||||
<BuildVersionCard/>
|
||||
|
||||
<ChangePasswordCard/>
|
||||
|
||||
<DeleteAccountCard email={profile?.email}/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,3 +9,14 @@ export const deleteAccount = (password) =>
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({password}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/password on Auth: 204 (the other logins end, this one stays), 403 INVALID_PASSWORD, 429 with
|
||||
* Retry-After in seconds.
|
||||
*/
|
||||
export const changePassword = (currentPassword, newPassword) =>
|
||||
apiFetch(`${import.meta.env.VITE_API_URL}/api/auth/password`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({currentPassword, newPassword}),
|
||||
});
|
||||
@@ -42,6 +42,12 @@ export const deleteUnconfirmedUser = (userId) =>
|
||||
|
||||
export const deleteUser = (userId) => apiFetch(`${AUTH()}/api/admin/users/${userId}`, {method: 'DELETE'});
|
||||
|
||||
/**
|
||||
* Mails the user a link to choose a new password: 204, or 409 NOT_ACTIVE, SELF_ACTION or TARGET_IS_ADMIN.
|
||||
*/
|
||||
export const sendPasswordReset = (userId) =>
|
||||
apiFetch(`${AUTH()}/api/admin/users/${userId}/password-reset`, {method: 'POST'});
|
||||
|
||||
/**
|
||||
* "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.
|
||||
|
||||
@@ -30,8 +30,9 @@ export const registrationSchema = z.object({
|
||||
displayName: displayNameField,
|
||||
});
|
||||
|
||||
export const confirmEmailSchema = z
|
||||
const newPasswordSchema = (fields = {}) => z
|
||||
.object({
|
||||
...fields,
|
||||
password: passwordField,
|
||||
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
|
||||
})
|
||||
@@ -40,6 +41,12 @@ export const confirmEmailSchema = z
|
||||
message: 'VALIDATION.PASSWORD_MISMATCH',
|
||||
});
|
||||
|
||||
export const confirmEmailSchema = newPasswordSchema();
|
||||
|
||||
export const changePasswordSchema = newPasswordSchema({
|
||||
currentPassword: z.string().min(1, 'VALIDATION.CURRENT_PASSWORD_REQUIRED'),
|
||||
});
|
||||
|
||||
export const settingsSchema = z.object({
|
||||
displayName: displayNameField,
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {confirmEmailSchema, registrationSchema, settingsSchema} from './validation.js';
|
||||
import {changePasswordSchema, confirmEmailSchema, registrationSchema, settingsSchema} from './validation.js';
|
||||
|
||||
const settings = () => ({useDarkTheme: true, language: 'de'});
|
||||
|
||||
@@ -46,6 +46,25 @@ describe('confirm page password', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('changePasswordSchema', () => {
|
||||
const valid = {currentPassword: 'old', password: 'password1', passwordConfirmation: 'password1'};
|
||||
|
||||
it('takes a current password and a good new one', () => {
|
||||
expect(changePasswordSchema.safeParse(valid).success).toBe(true);
|
||||
});
|
||||
|
||||
it('requires the current password', () => {
|
||||
expect(changePasswordSchema.safeParse({...valid, currentPassword: ''}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('applies the rules of the new password and the repeat', () => {
|
||||
expect(changePasswordSchema.safeParse({...valid, password: 'short', passwordConfirmation: 'short'}).success).toBe(false);
|
||||
expect(changePasswordSchema.safeParse({...valid, passwordConfirmation: 'password2'}).success).toBe(false);
|
||||
const long = 'é'.repeat(37);
|
||||
expect(changePasswordSchema.safeParse({...valid, password: long, passwordConfirmation: long}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registration', () => {
|
||||
it('has no password field', () => {
|
||||
expect(registrationSchema.parse({email: 'a@b.de', password: 'password1'})).not.toHaveProperty('password');
|
||||
|
||||
Reference in new issue
Block a user