diff --git a/packages/cmsui/config/routes.ts b/packages/cmsui/config/routes.ts index cccac5bec..310d87e6e 100644 --- a/packages/cmsui/config/routes.ts +++ b/packages/cmsui/config/routes.ts @@ -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/` (no hyphen) rather than `/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', diff --git a/packages/cmsui/locales/de/common.json b/packages/cmsui/locales/de/common.json index 6d8e29cd7..9cc2df93c 100644 --- a/packages/cmsui/locales/de/common.json +++ b/packages/cmsui/locales/de/common.json @@ -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? Neues Passwort anfordern", - "returnToHome": "Zur Startseite zurückkehren" + "forgotPassword": "Passwort vergessen? Neues Passwort anfordern.", + "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" diff --git a/packages/cmsui/locales/en/common.json b/packages/cmsui/locales/en/common.json index 20968727e..713ba1b22 100644 --- a/packages/cmsui/locales/en/common.json +++ b/packages/cmsui/locales/en/common.json @@ -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 new password", - "returnToHome": "Return to home page" + "forgotPassword": "Forgot your password? Request a new password.", + "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" diff --git a/packages/cmsui/locales/it/common.json b/packages/cmsui/locales/it/common.json index 22f8a746d..74cc4b25f 100644 --- a/packages/cmsui/locales/it/common.json +++ b/packages/cmsui/locales/it/common.json @@ -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? Richiedi una nuova password", - "returnToHome": "Torna alla home page" + "forgotPassword": "Password dimenticata? Richiedi una nuova password.", + "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" diff --git a/packages/cmsui/routes/auth/reset-password.test.tsx b/packages/cmsui/routes/auth/reset-password.test.tsx new file mode 100644 index 000000000..cb9da0eda --- /dev/null +++ b/packages/cmsui/routes/auth/reset-password.test.tsx @@ -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) { + 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) { + 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; +} + +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', + }); + }); +}); diff --git a/packages/cmsui/routes/auth/reset-password.tsx b/packages/cmsui/routes/auth/reset-password.tsx new file mode 100644 index 000000000..5fd1d38f0 --- /dev/null +++ b/packages/cmsui/routes/auth/reset-password.tsx @@ -0,0 +1,486 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Form, + useActionData, + useLoaderData, + useLocation, + useParams, + type ActionFunctionArgs, + type LoaderFunctionArgs, + RouterContextProvider, + type MetaFunction, +} from 'react-router'; +import { + ploneClientContext, + ploneContentContext, +} from '@plone/aurora/app/middleware.server'; +import { redirectIfLoggedInLoader } from '@plone/react-router'; +import { TextField, Link, Button } from '@plone/components/quanta'; +import CloseSVG from '@plone/components/icons/close.svg?react'; +import ArrowRightSVG from '@plone/components/icons/arrow-right.svg?react'; +import SlotRenderer from '@plone/layout/slots/SlotRenderer'; +import { useTranslation } from 'react-i18next'; +import type { RootLoader } from '@plone/aurora/app/root'; + +export async function loader(args: LoaderFunctionArgs) { + await redirectIfLoggedInLoader(args); + const content = args.context.get(ploneContentContext); + return { content }; +} + +export function headers() { + return { 'Referrer-Policy': 'no-referrer' }; +} + +export const meta: MetaFunction = ({ + matches, +}) => { + const rootData = matches.find((match) => match.id === 'root')?.data; + const siteTitle = rootData?.site?.['plone.site_title']; + return [ + { + title: siteTitle ? `Reset password - ${siteTitle}` : 'Reset password', + }, + ]; +}; + +// TODO: Acquire the real password policy (length/complexity) from the Plone. +// REST API instead of hard-coding it. Plone remains the authoritative check. +const MIN_PASSWORD_LENGTH = 8; +const USERNAME_MIN_LENGTH = 2; + +// Mirror of Plone's @controlpanels/security `use_email_as_login` setting. +// When false (Plone default), only the canonical username is accepted as the +// path segment in @users/{id}/reset-password. +// Flip to true if your Plone install enables email-as-login. +// TODO: Acquire setting from Plone directly. +const USE_EMAIL_AS_LOGIN = false; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export type ValidationFailure = { + code: string; + params?: Record; +} | null; + +export function validateIdentifier(value: string): ValidationFailure { + const v = value.trim(); + if (!v) return { code: 'identifierRequired' }; + if (v.includes('@')) { + if (!USE_EMAIL_AS_LOGIN) return { code: 'usernameNoEmail' }; + return EMAIL_RE.test(v) ? null : { code: 'invalidEmail' }; + } + if (v.length < USERNAME_MIN_LENGTH) { + return { code: 'usernameTooShort', params: { min: USERNAME_MIN_LENGTH } }; + } + if (/\s/.test(v)) return { code: 'usernameNoSpaces' }; + return null; +} + +export function validatePassword(value: string): ValidationFailure { + if (value.length < MIN_PASSWORD_LENGTH) { + return { code: 'passwordTooShort', params: { min: MIN_PASSWORD_LENGTH } }; + } + return null; +} + +export function validatePasswordMatch( + password: string, + confirm: string, +): ValidationFailure { + if (password !== confirm) return { code: 'passwordsDoNotMatch' }; + return null; +} + +type ResultField = 'identifier' | 'newPassword' | 'confirmPassword' | 'form'; + +type ResetPasswordActionResult = + | { ok: true; mode: 'request' | 'set' } + | { + ok: false; + mode: 'request' | 'set'; + field: ResultField; + code: string; + params?: Record; + }; + +export async function action({ + request, + context, +}: ActionFunctionArgs) { + const cli = context.get(ploneClientContext); + const formData = await request.formData(); + const mode = String(formData.get('mode') || 'request'); + const id = String(formData.get('usernameOrEmail') || '').trim(); + + if (mode === 'set') { + const reset_token = String(formData.get('token') || ''); + const new_password = String(formData.get('new_password') || ''); + const confirm = String(formData.get('confirm_password') || ''); + + const idError = validateIdentifier(id); + if (idError) { + return { + ok: false, + mode: 'set', + field: 'identifier', + ...idError, + } satisfies ResetPasswordActionResult; + } + if (!reset_token) { + return { + ok: false, + mode: 'set', + field: 'form', + code: 'setFailed', + } satisfies ResetPasswordActionResult; + } + const passwordError = validatePassword(new_password); + if (passwordError) { + return { + ok: false, + mode: 'set', + field: 'newPassword', + ...passwordError, + } satisfies ResetPasswordActionResult; + } + const matchError = validatePasswordMatch(new_password, confirm); + if (matchError) { + return { + ok: false, + mode: 'set', + field: 'confirmPassword', + ...matchError, + } satisfies ResetPasswordActionResult; + } + + try { + await cli.resetPasswordWithToken({ + id, + data: { reset_token, new_password }, + }); + return { ok: true, mode: 'set' } satisfies ResetPasswordActionResult; + } catch (error: any) { + const status = Number(error?.status) || 500; + return { + ok: false, + mode: 'set', + field: 'form', + code: status >= 500 ? 'serverError' : 'setFailed', + } satisfies ResetPasswordActionResult; + } + } + + if (!id) { + return { ok: true, mode: 'request' } satisfies ResetPasswordActionResult; + } + try { + await cli.resetPassword({ id }); + return { ok: true, mode: 'request' } satisfies ResetPasswordActionResult; + } catch (error: any) { + const status = Number(error?.status) || 500; + if (status >= 500) { + return { + ok: false, + mode: 'request', + field: 'form', + code: 'serverError', + } satisfies ResetPasswordActionResult; + } + return { ok: true, mode: 'request' } satisfies ResetPasswordActionResult; + } +} + +export default function ResetPassword() { + const { content } = useLoaderData(); + const actionResult = useActionData() as + | ResetPasswordActionResult + | undefined; + const location = useLocation(); + const params = useParams(); + const { t } = useTranslation(); + + const token = params.token ?? ''; + const hasToken = token.length > 0; + + const [newPassword, setNewPassword] = useState(''); + + const succeeded = actionResult?.ok === true; + + const successHeadingRef = useRef(null); + useEffect(() => { + if (succeeded) successHeadingRef.current?.focus(); + }, [succeeded]); + + const translateFailure = ( + failure: Extract, + ) => + t(`cmsui.auth.resetPassword.errors.${failure.code}`, failure.params) ?? + undefined; + + const fieldError = (field: ResultField): string | undefined => + actionResult && !actionResult.ok && actionResult.field === field + ? translateFailure(actionResult) + : undefined; + + const formError = fieldError('form'); + + const runValidator = + (validator: (value: string) => ValidationFailure) => + (value: string): string | null => { + const failure = validator(value); + return failure + ? t(`cmsui.auth.resetPassword.errors.${failure.code}`, failure.params) + : null; + }; + + const identifierVariant = USE_EMAIL_AS_LOGIN ? 'WithEmail' : ''; + const identifierLabel = USE_EMAIL_AS_LOGIN + ? t('cmsui.auth.usernameOrEmail') + : t('cmsui.auth.username'); + + const heading = hasToken + ? t('cmsui.auth.resetPassword.setTitle') + : t('cmsui.auth.resetPassword.requestTitle'); + + const description = hasToken + ? t('cmsui.auth.resetPassword.setDescription') + : t(`cmsui.auth.resetPassword.requestDescription${identifierVariant}`); + + if (succeeded) { + const isRequest = actionResult!.mode === 'request'; + const successTitle = isRequest + ? t('cmsui.auth.resetPassword.requestSuccessTitle') + : t('cmsui.auth.resetPassword.setSuccessTitle'); + const successBody = isRequest + ? t(`cmsui.auth.resetPassword.requestSuccessBody${identifierVariant}`) + : t('cmsui.auth.resetPassword.setSuccessBody'); + + return ( +
*:nth-child(2)]:grid-cols-[minmax(50%,1fr)_auto] + `} + > +
+
+ + + +
+ +

+ {successTitle} +

+

+ {successBody} +

+
+ {t('cmsui.auth.signIn')} +
+
+
+
+ +
+ ); + } + + return ( +
*:nth-child(2)]:grid-cols-[minmax(50%,1fr)_auto] + `} + > +
+
+ + + +
+ +

+ {heading} +

+

+ {description} +

+
+
+
+
+ + {hasToken && } + {formError && ( +
+ {formError} +
+ )} + + {hasToken && ( + <> + + { + const failure = validatePasswordMatch( + newPassword, + value, + ); + return failure + ? t( + `cmsui.auth.resetPassword.errors.${failure.code}`, + failure.params, + ) + : null; + }} + isInvalid={ + fieldError('confirmPassword') ? true : undefined + } + errorMessage={fieldError('confirmPassword')} + /> + + )} +
+ {!hasToken && ( + {t('cmsui.auth.signIn')} + )} + +
+ +
+
+
+
+ +
+ ); +}