Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/cmsui/config/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,42 @@ export default function install(config: ConfigType) {
},
],
},
{
type: 'prefix',
path: 'reset-password',
children: [
{
type: 'index',
file: '@plone/cmsui/routes/auth/reset-password.tsx',
options: { id: 'reset-password-index' },
},
{
type: 'route',
path: ':token',
file: '@plone/cmsui/routes/auth/reset-password.tsx',
options: { id: 'reset-password-token' },
},
],
},
{
// Alias for the URL Plone's password-reset email links to, which is
// `/passwordreset/<token>` (no hyphen) rather than `/reset-password`.
Comment on lines +30 to +49

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a specific reason for adding the reset-password route? Since the restapi already generates URLs with /passwordreset I would have only kept that one

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just kept it as i thought it was a necessity / design decision for the naming, since it was already defined here to some degree:

<Trans
i18nKey="cmsui.auth.forgotPassword"
components={{ a: <Link href="/reset-password" /> }}
/>

type: 'prefix',
path: 'passwordreset',
children: [
{
type: 'index',
file: '@plone/cmsui/routes/auth/reset-password.tsx',
options: { id: 'passwordreset-index' },
},
{
type: 'route',
path: ':token',
file: '@plone/cmsui/routes/auth/reset-password.tsx',
options: { id: 'passwordreset-token' },
},
],
},
{
type: 'prefix',
path: '@@add',
Expand Down
35 changes: 33 additions & 2 deletions packages/cmsui/locales/de/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,43 @@
},
"auth": {
"username": "Benutzername",
"usernameOrEmail": "Benutzername oder E-Mail",
"password": "Passwort",
"signIn": "Anmelden",
"signUp": "Registrieren",
"signInTo": "Bei {{site}} anmelden",
"forgotPassword": "Passwort vergessen? <a>Neues Passwort anfordern</a>",
"returnToHome": "Zur Startseite zurückkehren"
"forgotPassword": "Passwort vergessen? <a>Neues Passwort anfordern</a>.",
"returnToHome": "Zur Startseite zurückkehren",
"resetPassword": {
"requestTitle": "Passwort zurücksetzen",
"requestDescription": "Geben Sie Ihren Benutzernamen ein und wir senden Ihnen einen Link, um ein neues Passwort zu vergeben.",
"requestDescriptionWithEmail": "Geben Sie Ihren Benutzernamen oder Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link, um ein neues Passwort zu vergeben.",
"requestSubmit": "Neues Passwort anfordern",
"requestSuccessTitle": "Bitte prüfen Sie Ihre E-Mails",
"requestSuccessBody": "Falls ein Konto mit diesem Benutzernamen existiert, haben wir eine Nachricht mit Anweisungen zum Zurücksetzen Ihres Passworts gesendet.",
"requestSuccessBodyWithEmail": "Falls ein Konto mit diesem Benutzernamen oder dieser E-Mail existiert, haben wir eine Nachricht mit Anweisungen zum Zurücksetzen Ihres Passworts gesendet.",
"setTitle": "Neues Passwort festlegen",
"setDescription": "Wählen Sie ein neues Passwort für Ihr Konto.",
"setSubmit": "Neues Passwort speichern",
"setSuccessTitle": "Passwort aktualisiert",
"setSuccessBody": "Ihr Passwort wurde gesetzt. Sie können sich nun mit dem neuen Passwort anmelden.",
"setIdentifierHint": "Geben Sie den Benutzernamen ein, mit dem Sie die Zurücksetzung angefordert haben.",
"setIdentifierHintWithEmail": "Geben Sie den Benutzernamen oder die E-Mail-Adresse ein, mit der Sie die Zurücksetzung angefordert haben.",
"newPassword": "Neues Passwort",
"confirmPassword": "Neues Passwort bestätigen",
"passwordHint": "Mindestens {{min}} Zeichen verwenden.",
"errors": {
"identifierRequired": "Bitte geben Sie einen Benutzernamen ein.",
"usernameNoEmail": "Bitte geben Sie Ihren Benutzernamen ein, keine E-Mail-Adresse.",
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
"usernameTooShort": "Der Benutzername muss mindestens {{min}} Zeichen lang sein.",
"usernameNoSpaces": "Der Benutzername darf keine Leerzeichen enthalten.",
"passwordTooShort": "Das Passwort muss mindestens {{min}} Zeichen lang sein.",
"passwordsDoNotMatch": "Die Passwörter stimmen nicht überein.",
"setFailed": "Ihr neues Passwort konnte nicht gesetzt werden. Möglicherweise ist Ihr Link ungültig oder abgelaufen. Bitte fordern Sie einen neuen an.",
"serverError": "Auf unserer Seite ist ein Fehler aufgetreten. Bitte versuchen Sie es in Kürze erneut."
}
}
},
"toolbar": {
"settings": "Einstellungen"
Expand Down
35 changes: 33 additions & 2 deletions packages/cmsui/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,43 @@
},
"auth": {
"username": "username",
"usernameOrEmail": "Username or email",
"password": "password",
"signIn": "Sign in",
"signUp": "Sign up",
"signInTo": "Sign in to {{site}}",
"forgotPassword": "Forgot password? Request a <a>new password</a>",
"returnToHome": "Return to home page"
"forgotPassword": "Forgot your password? Request a <a>new password</a>.",
"returnToHome": "Return to home page",
"resetPassword": {
"requestTitle": "Reset your password",
"requestDescription": "Enter your username and we will send you a link to set a new password.",
"requestDescriptionWithEmail": "Enter your username or email and we will send you a link to set a new password.",
"requestSubmit": "Request new password",
"requestSuccessTitle": "Check your inbox",
"requestSuccessBody": "If an account exists for that username, we have sent a message with instructions to reset your password.",
"requestSuccessBodyWithEmail": "If an account exists for that username or email, we have sent a message with instructions to reset your password.",
"setTitle": "Set a new password",
"setDescription": "Choose a new password for your account.",
"setSubmit": "Set new password",
"setSuccessTitle": "Password updated",
"setSuccessBody": "Your password has been set. You can now sign in with your new password.",
"setIdentifierHint": "Enter the username you used to request the reset.",
"setIdentifierHintWithEmail": "Enter the username or email you used to request the reset.",
"newPassword": "New password",
"confirmPassword": "Confirm new password",
"passwordHint": "Use at least {{min}} characters.",
"errors": {
"identifierRequired": "Please enter a username.",
"usernameNoEmail": "Please enter your username, not an email address.",
"invalidEmail": "Please enter a valid email address.",
"usernameTooShort": "Username must be at least {{min}} characters.",
"usernameNoSpaces": "Username cannot contain spaces.",
"passwordTooShort": "Password must be at least {{min}} characters.",
"passwordsDoNotMatch": "Passwords do not match.",
"setFailed": "We couldn't set your new password. Your reset link may be invalid or expired. Please request a new one.",
"serverError": "Something went wrong on our end. Please try again in a few moments."
}
}
},
"toolbar": {
"settings": "Settings"
Expand Down
35 changes: 33 additions & 2 deletions packages/cmsui/locales/it/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,43 @@
},
"auth": {
"username": "nome utente",
"usernameOrEmail": "Nome utente o email",
"password": "password",
"signIn": "Accedi",
"signUp": "Registrati",
"signInTo": "Accedi a {{site}}",
"forgotPassword": "Password dimenticata? <a>Richiedi una nuova password</a>",
"returnToHome": "Torna alla home page"
"forgotPassword": "Password dimenticata? <a>Richiedi una nuova password</a>.",
"returnToHome": "Torna alla home page",
"resetPassword": {
"requestTitle": "Reimposta la tua password",
"requestDescription": "Inserisci il tuo nome utente e ti invieremo un link per impostare una nuova password.",
"requestDescriptionWithEmail": "Inserisci il tuo nome utente o la tua email e ti invieremo un link per impostare una nuova password.",
"requestSubmit": "Richiedi una nuova password",
"requestSuccessTitle": "Controlla la tua casella di posta",
"requestSuccessBody": "Se esiste un account per questo nome utente, ti abbiamo inviato un messaggio con le istruzioni per reimpostare la password.",
"requestSuccessBodyWithEmail": "Se esiste un account per questo nome utente o email, ti abbiamo inviato un messaggio con le istruzioni per reimpostare la password.",
"setTitle": "Imposta una nuova password",
"setDescription": "Scegli una nuova password per il tuo account.",
"setSubmit": "Imposta nuova password",
"setSuccessTitle": "Password aggiornata",
"setSuccessBody": "La tua password è stata impostata. Ora puoi accedere con la nuova password.",
"setIdentifierHint": "Inserisci il nome utente che hai usato per richiedere la reimpostazione.",
"setIdentifierHintWithEmail": "Inserisci il nome utente o l'email che hai usato per richiedere la reimpostazione.",
"newPassword": "Nuova password",
"confirmPassword": "Conferma nuova password",
"passwordHint": "Usa almeno {{min}} caratteri.",
"errors": {
"identifierRequired": "Inserisci un nome utente.",
"usernameNoEmail": "Inserisci il tuo nome utente, non un indirizzo email.",
"invalidEmail": "Inserisci un indirizzo email valido.",
"usernameTooShort": "Il nome utente deve contenere almeno {{min}} caratteri.",
"usernameNoSpaces": "Il nome utente non può contenere spazi.",
"passwordTooShort": "La password deve contenere almeno {{min}} caratteri.",
"passwordsDoNotMatch": "Le password non coincidono.",
"setFailed": "Non è stato possibile impostare la tua nuova password. Il link potrebbe essere non valido o scaduto. Richiedine uno nuovo.",
"serverError": "Si è verificato un errore dal nostro lato. Riprova tra qualche istante."
}
}
},
"toolbar": {
"settings": "Impostazioni"
Expand Down
204 changes: 204 additions & 0 deletions packages/cmsui/routes/auth/reset-password.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { expect, describe, it, vi, afterEach } from 'vitest';
import { RouterContextProvider } from 'react-router';
import { ploneClientContext } from '@plone/aurora/app/middleware.server';
import {
action,
validateIdentifier,
validatePassword,
validatePasswordMatch,
} from './reset-password';

vi.mock('@plone/react-router', () => ({
getAuthFromRequest: vi.fn().mockResolvedValue(undefined),
}));

function buildRequest(fields: Record<string, string>) {
const body = new URLSearchParams(fields);
return new Request('http://example.com/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
}

function callAction(request: Request, cli: Record<string, unknown>) {
const context = new RouterContextProvider();
context.set(ploneClientContext, cli as any);
return action({
request,
params: {},
context,
unstable_pattern: '/reset-password',
unstable_url: new URL(request.url),
}) as Promise<any>;
}

describe('reset-password validators', () => {
it('rejects an empty identifier', () => {
expect(validateIdentifier(' ')).toEqual({ code: 'identifierRequired' });
});

it('rejects an email when email-as-login is disabled', () => {
expect(validateIdentifier('foo@bar.com')).toEqual({
code: 'usernameNoEmail',
});
});

it('rejects a too-short username and one with spaces', () => {
expect(validateIdentifier('a')).toEqual({
code: 'usernameTooShort',
params: { min: 2 },
});
expect(validateIdentifier('john doe')).toEqual({
code: 'usernameNoSpaces',
});
expect(validateIdentifier('johndoe')).toBeNull();
});

it('enforces the minimum password length', () => {
expect(validatePassword('short')).toEqual({
code: 'passwordTooShort',
params: { min: 8 },
});
expect(validatePassword('longenough')).toBeNull();
});

it('detects password mismatches', () => {
expect(validatePasswordMatch('abcdefgh', 'abcdefgi')).toEqual({
code: 'passwordsDoNotMatch',
});
expect(validatePasswordMatch('abcdefgh', 'abcdefgh')).toBeNull();
});
});

describe('reset-password action — request flow', () => {
afterEach(() => vi.restoreAllMocks());

it('calls resetPassword with the trimmed identifier', async () => {
const resetPassword = vi.fn().mockResolvedValue({});
const result = await callAction(
buildRequest({ mode: 'request', usernameOrEmail: ' john ' }),
{ resetPassword },
);
expect(resetPassword).toHaveBeenCalledWith({ id: 'john' });
expect(result).toEqual({ ok: true, mode: 'request' });
});

it('reports success on a 4xx to avoid user enumeration', async () => {
const resetPassword = vi.fn().mockRejectedValue({ status: 404 });
const result = await callAction(
buildRequest({ mode: 'request', usernameOrEmail: 'ghost' }),
{ resetPassword },
);
expect(result).toEqual({ ok: true, mode: 'request' });
});

it('surfaces a generic server error on a 5xx', async () => {
const resetPassword = vi.fn().mockRejectedValue({ status: 500 });
const result = await callAction(
buildRequest({ mode: 'request', usernameOrEmail: 'john' }),
{ resetPassword },
);
expect(result).toEqual({
ok: false,
mode: 'request',
field: 'form',
code: 'serverError',
});
});

it('skips the call but reports success when the identifier is empty', async () => {
const resetPassword = vi.fn();
const result = await callAction(
buildRequest({ mode: 'request', usernameOrEmail: '' }),
{ resetPassword },
);
expect(resetPassword).not.toHaveBeenCalled();
expect(result).toEqual({ ok: true, mode: 'request' });
});
});

describe('reset-password action — set flow', () => {
afterEach(() => vi.restoreAllMocks());

const validSet = {
mode: 'set',
usernameOrEmail: 'john',
token: 'tok-123',
new_password: 'longenough',
confirm_password: 'longenough',
};

it('calls resetPasswordWithToken with the token and password', async () => {
const resetPasswordWithToken = vi.fn().mockResolvedValue({});
const result = await callAction(buildRequest(validSet), {
resetPasswordWithToken,
});
expect(resetPasswordWithToken).toHaveBeenCalledWith({
id: 'john',
data: { reset_token: 'tok-123', new_password: 'longenough' },
});
expect(result).toEqual({ ok: true, mode: 'set' });
});

it('rejects a too-short password against the new-password field', async () => {
const resetPasswordWithToken = vi.fn();
const result = await callAction(
buildRequest({
...validSet,
new_password: 'short',
confirm_password: 'short',
}),
{ resetPasswordWithToken },
);
expect(resetPasswordWithToken).not.toHaveBeenCalled();
expect(result).toEqual({
ok: false,
mode: 'set',
field: 'newPassword',
code: 'passwordTooShort',
params: { min: 8 },
});
});

it('reports a mismatch against the confirm field', async () => {
const result = await callAction(
buildRequest({ ...validSet, confirm_password: 'different1' }),
{ resetPasswordWithToken: vi.fn() },
);
expect(result).toEqual({
ok: false,
mode: 'set',
field: 'confirmPassword',
code: 'passwordsDoNotMatch',
});
});

it('fails on a missing token', async () => {
const result = await callAction(buildRequest({ ...validSet, token: '' }), {
resetPasswordWithToken: vi.fn(),
});
expect(result).toEqual({
ok: false,
mode: 'set',
field: 'form',
code: 'setFailed',
});
});

it('maps a 4xx from the backend to a generic setFailed error', async () => {
const resetPasswordWithToken = vi.fn().mockRejectedValue({
status: 400,
data: { error: { message: 'Invalid token internal detail' } },
});
const result = await callAction(buildRequest(validSet), {
resetPasswordWithToken,
});
expect(result).toEqual({
ok: false,
mode: 'set',
field: 'form',
code: 'setFailed',
});
});
});
Loading
Loading