From 6627a8db66c3fb6f84a4f81fd8a30c985ae7858b Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 2 Oct 2026 10:35:08 +0200 Subject: [PATCH] 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. --- README.md | 5 + public/i18n/de.json | 31 +++++ public/i18n/en.json | 31 +++++ src/App.jsx | 2 + src/components/ChangePasswordCard.jsx | 73 +++++++++++ src/components/ChangePasswordCard.test.jsx | 72 +++++++++++ src/components/NewPasswordFields.jsx | 19 +++ src/pages/AdminUsersPage.jsx | 18 ++- src/pages/AdminUsersPage.test.jsx | 29 +++++ src/pages/ConfirmEmailPage.jsx | 22 +--- src/pages/LoginPage.jsx | 3 + src/pages/LoginPage.test.jsx | 6 + src/pages/ResetPasswordPage.jsx | 137 +++++++++++++++++++++ src/pages/ResetPasswordPage.test.jsx | 92 ++++++++++++++ src/pages/SettingsPage.jsx | 3 + src/utils/account.js | 11 ++ src/utils/admin.js | 6 + src/utils/validation.js | 9 +- src/utils/validation.test.js | 21 +++- 19 files changed, 565 insertions(+), 25 deletions(-) create mode 100644 src/components/ChangePasswordCard.jsx create mode 100644 src/components/ChangePasswordCard.test.jsx create mode 100644 src/components/NewPasswordFields.jsx create mode 100644 src/pages/ResetPasswordPage.jsx create mode 100644 src/pages/ResetPasswordPage.test.jsx diff --git a/README.md b/README.md index d407686..4da3ea3 100644 --- a/README.md +++ b/README.md @@ -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#` (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`. diff --git a/public/i18n/de.json b/public/i18n/de.json index 9753e00..772479b 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -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." }, diff --git a/public/i18n/en.json b/public/i18n/en.json index 9fafd16..3df95fd 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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." }, diff --git a/src/App.jsx b/src/App.jsx index fb79590..c8b1ebf 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 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() { }/> }/> }/> + }/> }> }/> }/> diff --git a/src/components/ChangePasswordCard.jsx b/src/components/ChangePasswordCard.jsx new file mode 100644 index 0000000..8ff2528 --- /dev/null +++ b/src/components/ChangePasswordCard.jsx @@ -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 ( + + + {t('SETTINGS.CHANGE_PASSWORD.TITLE')} + + +
+ + + + + + + + + + +
+
+
+ ); +} diff --git a/src/components/ChangePasswordCard.test.jsx b/src/components/ChangePasswordCard.test.jsx new file mode 100644 index 0000000..4b6c06d --- /dev/null +++ b/src/components/ChangePasswordCard.test.jsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/src/components/NewPasswordFields.jsx b/src/components/NewPasswordFields.jsx new file mode 100644 index 0000000..8d53691 --- /dev/null +++ b/src/components/NewPasswordFields.jsx @@ -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 <> + + + + + + + ; +} diff --git a/src/pages/AdminUsersPage.jsx b/src/pages/AdminUsersPage.jsx index 56ce7c3..39b2c89 100644 --- a/src/pages/AdminUsersPage.jsx +++ b/src/pages/AdminUsersPage.jsx @@ -1,6 +1,6 @@ import {useCallback, useEffect, useState} from 'react'; import {useTranslation} from 'react-i18next'; -import {Ban, 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' && (
+ {user.status === 'ACTIVE' && !user.blocked && ( + + )}
+ + + ); +} + +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 ( +
+

{t('RESET_PASSWORD.INTRO')}

+ + + + + ); +} diff --git a/src/pages/ResetPasswordPage.test.jsx b/src/pages/ResetPasswordPage.test.jsx new file mode 100644 index 0000000..758c3d0 --- /dev/null +++ b/src/pages/ResetPasswordPage.test.jsx @@ -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( + , +); + +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(); + }); +}); diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index 376da74..85e2b60 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -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() { + + ); diff --git a/src/utils/account.js b/src/utils/account.js index a802987..99c1e90 100644 --- a/src/utils/account.js +++ b/src/utils/account.js @@ -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}), + }); diff --git a/src/utils/admin.js b/src/utils/admin.js index 1221997..1ad1ccb 100644 --- a/src/utils/admin.js +++ b/src/utils/admin.js @@ -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. diff --git a/src/utils/validation.js b/src/utils/validation.js index db8dc6e..b6aea79 100644 --- a/src/utils/validation.js +++ b/src/utils/validation.js @@ -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, }); diff --git a/src/utils/validation.test.js b/src/utils/validation.test.js index 36de0d2..f00e8e8 100644 --- a/src/utils/validation.test.js +++ b/src/utils/validation.test.js @@ -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');