diff --git a/messages/en.json b/messages/en.json index c9ee7560..68458300 100644 --- a/messages/en.json +++ b/messages/en.json @@ -137,6 +137,46 @@ "invalidLink": "The verification link is missing required information.", "verificationFailed": "We could not verify your email with this link." }, + "authAction": { + "errorTitle": "Link not supported", + "errorDescription": "This account link can't be opened here.", + "unsupportedMode": "This link is for an action Mobility Database doesn't support.", + "missingCode": "This link is missing the security code it needs.", + "backToSignIn": "Go to sign in" + }, + "resetPassword": { + "verifyingTitle": "Checking your link", + "verifyingDescription": "Please wait while we confirm your password reset link.", + "title": "Choose a new password", + "description": "Enter a new password for your Mobility Database account.", + "descriptionForEmail": "Enter a new password for {email}.", + "newPasswordLabel": "New password", + "confirmPasswordLabel": "Confirm new password", + "togglePasswordVisibility": "Toggle password visibility", + "submit": "Reset password", + "submitting": "Resetting…", + "successTitle": "Password updated", + "successDescription": "Your password has been changed. You can now sign in with your new password.", + "goToSignIn": "Go to sign in", + "errorTitle": "Unable to reset your password", + "requestNewLink": "Request a new link", + "validation": { + "newPasswordRequired": "A new password is required.", + "confirmPasswordRequired": "Please confirm your new password.", + "complexity": "Password must be at least 12 characters and include an uppercase letter, a lowercase letter, a number and a special character.", + "mismatch": "Passwords must match." + }, + "errors": { + "expiredCode": "This reset link has expired. Request a new one to continue.", + "invalidCode": "This reset link is invalid or has already been used. Request a new one to continue.", + "userDisabled": "This account has been disabled. Contact us for help.", + "userNotFound": "We couldn't find an account for this reset link.", + "weakPassword": "That password is too weak. Please choose a stronger one.", + "tooManyRequests": "Too many attempts. Please try again in a few minutes.", + "networkError": "We couldn't reach our servers. Check your connection and try again.", + "generic": "We couldn't reset your password. Please try again." + } + }, "feeds": { "feeds": "Feeds", "dataType": "Data Format", diff --git a/messages/fr.json b/messages/fr.json index 914395bb..fa2a786a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -137,6 +137,46 @@ "invalidLink": "Le lien de vérification ne contient pas les informations requises.", "verificationFailed": "Nous n'avons pas pu vérifier votre e-mail avec ce lien." }, + "authAction": { + "errorTitle": "Lien non pris en charge", + "errorDescription": "Ce lien de compte ne peut pas être ouvert ici.", + "unsupportedMode": "Ce lien correspond à une action que Mobility Database ne prend pas en charge.", + "missingCode": "Il manque à ce lien le code de sécurité nécessaire.", + "backToSignIn": "Aller à la connexion" + }, + "resetPassword": { + "verifyingTitle": "Vérification de votre lien", + "verifyingDescription": "Veuillez patienter pendant que nous vérifions votre lien de réinitialisation.", + "title": "Choisissez un nouveau mot de passe", + "description": "Saisissez un nouveau mot de passe pour votre compte Mobility Database.", + "descriptionForEmail": "Saisissez un nouveau mot de passe pour {email}.", + "newPasswordLabel": "Nouveau mot de passe", + "confirmPasswordLabel": "Confirmez le nouveau mot de passe", + "togglePasswordVisibility": "Afficher ou masquer le mot de passe", + "submit": "Réinitialiser le mot de passe", + "submitting": "Réinitialisation…", + "successTitle": "Mot de passe mis à jour", + "successDescription": "Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.", + "goToSignIn": "Aller à la connexion", + "errorTitle": "Impossible de réinitialiser votre mot de passe", + "requestNewLink": "Demander un nouveau lien", + "validation": { + "newPasswordRequired": "Le nouveau mot de passe est obligatoire.", + "confirmPasswordRequired": "Veuillez confirmer votre nouveau mot de passe.", + "complexity": "Le mot de passe doit comporter au moins 12 caractères et inclure une majuscule, une minuscule, un chiffre et un caractère spécial.", + "mismatch": "Les mots de passe doivent correspondre." + }, + "errors": { + "expiredCode": "Ce lien de réinitialisation a expiré. Demandez-en un nouveau pour continuer.", + "invalidCode": "Ce lien de réinitialisation est invalide ou a déjà été utilisé. Demandez-en un nouveau pour continuer.", + "userDisabled": "Ce compte a été désactivé. Contactez-nous pour obtenir de l’aide.", + "userNotFound": "Nous n’avons trouvé aucun compte correspondant à ce lien de réinitialisation.", + "weakPassword": "Ce mot de passe est trop faible. Veuillez en choisir un plus robuste.", + "tooManyRequests": "Trop de tentatives. Veuillez réessayer dans quelques minutes.", + "networkError": "Nous n’avons pas pu joindre nos serveurs. Vérifiez votre connexion et réessayez.", + "generic": "Nous n’avons pas pu réinitialiser votre mot de passe. Veuillez réessayer." + } + }, "feeds": { "feeds": "Feeds", "dataType": "Data Format", diff --git a/src/app/[locale]/auth/action/AuthActionError.tsx b/src/app/[locale]/auth/action/AuthActionError.tsx new file mode 100644 index 00000000..84853aac --- /dev/null +++ b/src/app/[locale]/auth/action/AuthActionError.tsx @@ -0,0 +1,56 @@ +'use client'; + +import * as React from 'react'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import { Alert, Button, Stack, Typography, useTheme } from '@mui/material'; +import { useTranslations } from 'next-intl'; +import { ContentBox } from '../../../components/ContentBox'; +import { Link } from '../../../../i18n/navigation'; +import { type AuthActionErrorReason } from './lib/auth-actions'; + +interface AuthActionErrorProps { + reason: AuthActionErrorReason; +} + +/** + * Shown when Firebase sends us to the action URL with a mode we don't handle, + * or without the one-time code the action needs. + */ +export default function AuthActionError({ + reason, +}: AuthActionErrorProps): React.ReactElement { + const t = useTranslations('authAction'); + const theme = useTheme(); + + return ( + + + + + + {t('errorTitle')} + + + {t('errorDescription')} + + + + {t(reason)} + + + + + ); +} diff --git a/src/app/[locale]/auth/action/lib/auth-actions.spec.ts b/src/app/[locale]/auth/action/lib/auth-actions.spec.ts new file mode 100644 index 00000000..e43b6c4a --- /dev/null +++ b/src/app/[locale]/auth/action/lib/auth-actions.spec.ts @@ -0,0 +1,122 @@ +// `src/i18n/routing` pulls in next-intl's ESM build, which next/jest always +// leaves untransformed (it hard-codes /node_modules/ ahead of any custom +// transformIgnorePatterns). Same mock as proxy-helpers.spec.ts. +jest.mock('../../../../../i18n/routing', () => ({ + AVAILABLE_LOCALES: ['en', 'fr'], + routing: { + defaultLocale: 'en', + locales: ['en', 'fr'], + }, +})); + +import { + isSupportedAuthActionMode, + resolveActionLocale, + resolveAuthAction, + toLocale, +} from './auth-actions'; + +describe('isSupportedAuthActionMode', () => { + it('accepts the modes the app handles', () => { + expect(isSupportedAuthActionMode('verifyEmail')).toBe(true); + expect(isSupportedAuthActionMode('resetPassword')).toBe(true); + }); + + it('rejects other Firebase modes and missing values', () => { + expect(isSupportedAuthActionMode('recoverEmail')).toBe(false); + expect(isSupportedAuthActionMode('revertSecondFactorAddition')).toBe(false); + expect(isSupportedAuthActionMode('')).toBe(false); + expect(isSupportedAuthActionMode(undefined)).toBe(false); + }); +}); + +describe('resolveActionLocale', () => { + it('falls back when Firebase sends no language', () => { + expect(resolveActionLocale(undefined, 'en')).toBe('en'); + expect(resolveActionLocale(undefined, 'fr')).toBe('fr'); + }); + + it('uses a supported language', () => { + expect(resolveActionLocale('fr', 'en')).toBe('fr'); + }); + + it('strips the region from a qualified tag', () => { + expect(resolveActionLocale('fr-CA', 'en')).toBe('fr'); + expect(resolveActionLocale('EN-GB', 'fr')).toBe('en'); + }); + + it('falls back for a language the app does not ship', () => { + expect(resolveActionLocale('de', 'en')).toBe('en'); + expect(resolveActionLocale('', 'fr')).toBe('fr'); + }); +}); + +describe('toLocale', () => { + it('passes through known locales', () => { + expect(toLocale('fr')).toBe('fr'); + }); + + it('defaults for unknown or missing values', () => { + expect(toLocale('de')).toBe('en'); + expect(toLocale(undefined)).toBe('en'); + }); +}); + +describe('resolveAuthAction', () => { + it('routes email verification to the verification page', () => { + expect( + resolveAuthAction({ mode: 'verifyEmail', oobCode: 'abc' }, 'en'), + ).toEqual({ + status: 'redirect', + pathname: '/email-verification', + query: { mode: 'verifyEmail', oobCode: 'abc' }, + locale: 'en', + }); + }); + + it('routes password reset to the reset page', () => { + expect( + resolveAuthAction({ mode: 'resetPassword', oobCode: 'abc' }, 'en'), + ).toEqual({ + status: 'redirect', + pathname: '/reset-password', + query: { mode: 'resetPassword', oobCode: 'abc' }, + locale: 'en', + }); + }); + + it("honours the recipient's language over the request locale", () => { + const resolution = resolveAuthAction( + { mode: 'resetPassword', oobCode: 'abc', lang: 'fr' }, + 'en', + ); + expect(resolution).toMatchObject({ status: 'redirect', locale: 'fr' }); + }); + + it('reports an unsupported mode', () => { + expect( + resolveAuthAction({ mode: 'recoverEmail', oobCode: 'abc' }, 'en'), + ).toEqual({ status: 'error', reason: 'unsupportedMode' }); + expect(resolveAuthAction({ oobCode: 'abc' }, 'en')).toEqual({ + status: 'error', + reason: 'unsupportedMode', + }); + }); + + it('reports a missing one-time code', () => { + expect(resolveAuthAction({ mode: 'resetPassword' }, 'en')).toEqual({ + status: 'error', + reason: 'missingCode', + }); + expect( + resolveAuthAction({ mode: 'resetPassword', oobCode: ' ' }, 'en'), + ).toEqual({ status: 'error', reason: 'missingCode' }); + }); + + it('checks the mode before the code so a junk link is not mislabelled', () => { + expect(resolveAuthAction({}, 'en')).toEqual({ + status: 'error', + reason: 'unsupportedMode', + }); + }); +}); diff --git a/src/app/[locale]/auth/action/lib/auth-actions.ts b/src/app/[locale]/auth/action/lib/auth-actions.ts new file mode 100644 index 00000000..77c7510c --- /dev/null +++ b/src/app/[locale]/auth/action/lib/auth-actions.ts @@ -0,0 +1,108 @@ +import { + AVAILABLE_LOCALES, + routing, + type Locale, +} from '../../../../../i18n/routing'; + +/** + * Firebase routes every account email (verify email, password reset, email + * recovery, ...) to the single "action URL" configured in the Firebase console + * and tells them apart only by the `mode` query parameter. This module maps the + * modes we support onto the pages that handle them. + * + * Everything here is pure so it can be unit tested without rendering. + * + * @see https://firebase.google.com/docs/auth/custom-email-handler + */ + +/** The only `mode` values this app knows how to handle. */ +export const SUPPORTED_AUTH_ACTION_MODES = [ + 'verifyEmail', + 'resetPassword', +] as const; + +export type SupportedAuthActionMode = + (typeof SUPPORTED_AUTH_ACTION_MODES)[number]; + +/** Locale-agnostic page that handles each mode. */ +const AUTH_ACTION_TARGETS: Record = { + verifyEmail: '/email-verification', + resetPassword: '/reset-password', +}; + +export type AuthActionErrorReason = 'unsupportedMode' | 'missingCode'; + +export type AuthActionResolution = + | { + status: 'redirect'; + pathname: string; + query: { mode: SupportedAuthActionMode; oobCode: string }; + locale: Locale; + } + | { status: 'error'; reason: AuthActionErrorReason }; + +export interface AuthActionSearchParams { + mode?: string; + oobCode?: string; + /** Firebase appends the recipient's language, e.g. `en`, `fr` or `fr-CA`. */ + lang?: string; +} + +export function isSupportedAuthActionMode( + mode: string | undefined, +): mode is SupportedAuthActionMode { + return ( + mode !== undefined && + (SUPPORTED_AUTH_ACTION_MODES as readonly string[]).includes(mode) + ); +} + +export function isLocale(value: string): value is Locale { + return (AVAILABLE_LOCALES as readonly string[]).includes(value); +} + +/** + * Picks the locale to hand the action page. The action URL is a single, + * unprefixed URL, so the recipient's language only reaches us through + * Firebase's `lang` parameter; anything we don't ship falls back to the locale + * the request was already resolved to. + */ +export function resolveActionLocale( + lang: string | undefined, + fallbackLocale: Locale, +): Locale { + if (lang === undefined) { + return fallbackLocale; + } + // Firebase may send a region-qualified tag such as `fr-CA`. + const language = lang.trim().toLowerCase().split('-')[0]; + return isLocale(language) ? language : fallbackLocale; +} + +/** Narrows an unvalidated route param to a locale, defaulting when unknown. */ +export function toLocale(value: string | undefined): Locale { + return value !== undefined && isLocale(value) ? value : routing.defaultLocale; +} + +export function resolveAuthAction( + searchParams: AuthActionSearchParams, + fallbackLocale: Locale, +): AuthActionResolution { + const { mode, oobCode, lang } = searchParams; + + if (!isSupportedAuthActionMode(mode)) { + return { status: 'error', reason: 'unsupportedMode' }; + } + + if (oobCode === undefined || oobCode.trim() === '') { + return { status: 'error', reason: 'missingCode' }; + } + + return { + status: 'redirect', + pathname: AUTH_ACTION_TARGETS[mode], + // `mode` is forwarded so the destination page can keep validating it. + query: { mode, oobCode }, + locale: resolveActionLocale(lang, fallbackLocale), + }; +} diff --git a/src/app/[locale]/auth/action/page.tsx b/src/app/[locale]/auth/action/page.tsx new file mode 100644 index 00000000..31164ac8 --- /dev/null +++ b/src/app/[locale]/auth/action/page.tsx @@ -0,0 +1,63 @@ +import { type ReactElement } from 'react'; +import { type Metadata } from 'next'; +import { setRequestLocale } from 'next-intl/server'; +import { redirect } from '../../../../i18n/navigation'; +import AuthActionError from './AuthActionError'; +import { + resolveAuthAction, + toLocale, + type AuthActionSearchParams, +} from './lib/auth-actions'; + +export const metadata: Metadata = { + title: 'Account Action | MobilityDatabase', + description: + 'Handles account email actions sent by Mobility Database, such as email verification and password resets.', + robots: { + index: false, + follow: false, + googleBot: { + index: false, + follow: false, + 'max-image-preview': 'none', + 'max-snippet': -1, + 'max-video-preview': -1, + }, + }, +}; + +interface PageProps { + params: Promise<{ locale: string }>; + searchParams: Promise; +} + +/** + * Single entry point for Firebase account emails. + * + * The Firebase console holds one action URL for every email template, so this + * page reads the `mode` Firebase appends and forwards to the page that handles + * it. It renders only when the action is one we don't support. + */ +export default async function AuthActionPage({ + params, + searchParams, +}: PageProps): Promise { + const { locale } = await params; + const { mode, oobCode, lang } = await searchParams; + + setRequestLocale(locale); + + const resolution = resolveAuthAction( + { mode, oobCode, lang }, + toLocale(locale), + ); + + if (resolution.status === 'error') { + return ; + } + + return redirect({ + href: { pathname: resolution.pathname, query: resolution.query }, + locale: resolution.locale, + }); +} diff --git a/src/app/[locale]/email-verification/page.tsx b/src/app/[locale]/email-verification/page.tsx index 3b63fffb..8f026b15 100644 --- a/src/app/[locale]/email-verification/page.tsx +++ b/src/app/[locale]/email-verification/page.tsx @@ -2,6 +2,8 @@ import { type ReactElement } from 'react'; import { setRequestLocale } from 'next-intl/server'; import { type Metadata } from 'next'; import { type Locale, routing } from '../../../i18n/routing'; +import { redirect } from '../../../i18n/navigation'; +import { resolveAuthAction, toLocale } from '../auth/action/lib/auth-actions'; import EmailVerificationContent from './EmailVerificationContent'; export const metadata: Metadata = { @@ -32,6 +34,7 @@ interface PageProps { searchParams: Promise<{ mode?: string; oobCode?: string; + lang?: string; }>; } @@ -40,9 +43,25 @@ export default async function EmailVerificationPage({ searchParams, }: PageProps): Promise { const { locale } = await params; - const { mode, oobCode } = await searchParams; + const { mode, oobCode, lang } = await searchParams; setRequestLocale(locale); + // Back-compat: this route used to be the Firebase console's action URL for + // every template, so emails already in inboxes point other actions here. + // Forward those to the page that handles them; /auth/action covers new mail. + if (mode !== undefined && mode !== 'verifyEmail') { + const resolution = resolveAuthAction( + { mode, oobCode, lang }, + toLocale(locale), + ); + if (resolution.status === 'redirect') { + redirect({ + href: { pathname: resolution.pathname, query: resolution.query }, + locale: resolution.locale, + }); + } + } + return ; } diff --git a/src/app/[locale]/reset-password/ResetPassword.tsx b/src/app/[locale]/reset-password/ResetPassword.tsx new file mode 100644 index 00000000..f2fa20aa --- /dev/null +++ b/src/app/[locale]/reset-password/ResetPassword.tsx @@ -0,0 +1,338 @@ +'use client'; + +import * as React from 'react'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import { VisibilityOffOutlined, VisibilityOutlined } from '@mui/icons-material'; +import { + Alert, + Button, + CircularProgress, + IconButton, + InputAdornment, + Stack, + TextField, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; +import { useFormik } from 'formik'; +import { useTranslations } from 'next-intl'; +import * as Yup from 'yup'; +import { app } from '../../../firebase'; +import { ContentBox } from '../../components/ContentBox'; +import { Link } from '../../../i18n/navigation'; +import { passwordValidationRegex } from '../../constants/Validation'; +import { + isLinkUnusable, + mapResetPasswordError, + type ResetPasswordErrorKey, +} from './lib/reset-password-errors'; + +/** + * `verifying` covers the round trip that exchanges the one-time code for the + * account's email; `linkError` is terminal and can only be escaped by + * requesting a fresh reset email. + */ +type Stage = 'verifying' | 'form' | 'success' | 'linkError'; + +interface ResetPasswordProps { + oobCode?: string; +} + +export default function ResetPassword({ + oobCode, +}: ResetPasswordProps): React.ReactElement { + const t = useTranslations('resetPassword'); + const theme = useTheme(); + + const [stage, setStage] = React.useState('verifying'); + const [email, setEmail] = React.useState(null); + const [errorKey, setErrorKey] = React.useState( + null, + ); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [isSubmitted, setIsSubmitted] = React.useState(false); + const [showNewPassword, setShowNewPassword] = React.useState(false); + const [showConfirmPassword, setShowConfirmPassword] = React.useState(false); + + // Error state holds translation keys rather than rendered strings so `t` + // never has to be a dependency of the effect below. + React.useEffect(() => { + let cancelled = false; + + const verifyCode = async (): Promise => { + if (oobCode === undefined || oobCode.trim() === '') { + setErrorKey('invalidCode'); + setStage('linkError'); + return; + } + + try { + const verifiedEmail = await app.auth().verifyPasswordResetCode(oobCode); + if (cancelled) return; + setEmail(verifiedEmail); + setStage('form'); + } catch (error) { + if (cancelled) return; + setErrorKey(mapResetPasswordError(error)); + setStage('linkError'); + } + }; + + void verifyCode(); + + return () => { + cancelled = true; + }; + }, [oobCode]); + + const ResetPasswordSchema = Yup.object().shape({ + newPassword: Yup.string() + .required(t('validation.newPasswordRequired')) + .matches(passwordValidationRegex, t('validation.complexity')), + confirmPassword: Yup.string() + .required(t('validation.confirmPasswordRequired')) + .oneOf([Yup.ref('newPassword')], t('validation.mismatch')), + }); + + const confirmReset = async (newPassword: string): Promise => { + if (oobCode === undefined) return; + + setIsSubmitting(true); + setErrorKey(null); + + try { + await app.auth().confirmPasswordReset(oobCode, newPassword); + setStage('success'); + } catch (error) { + const key = mapResetPasswordError(error); + setErrorKey(key); + // A spent or rejected code can't be retried from the form. + if (isLinkUnusable(key)) { + setStage('linkError'); + } + } finally { + setIsSubmitting(false); + } + }; + + const formik = useFormik({ + initialValues: { newPassword: '', confirmPassword: '' }, + validationSchema: ResetPasswordSchema, + validateOnChange: isSubmitted, + validateOnBlur: true, + onSubmit: (values) => { + void confirmReset(values.newPassword); + }, + }); + + const passwordVisibilityAdornment = ( + isVisible: boolean, + toggle: () => void, + ): React.ReactElement => ( + + + + {isVisible ? ( + + ) : ( + + )} + + + + ); + + return ( + + {stage === 'verifying' && ( + + + + + {t('verifyingTitle')} + + + {t('verifyingDescription')} + + + + )} + + {stage === 'linkError' && ( + + + + + {t('errorTitle')} + + + {t(`errors.${errorKey ?? 'generic'}`)} + + + + + )} + + {stage === 'success' && ( + + + + + {t('successTitle')} + + + {t('successDescription')} + + + + + )} + + {stage === 'form' && ( + + + + {t('title')} + + + {email != null + ? t('descriptionForEmail', { email }) + : t('description')} + + + +
+ {/* Lets password managers associate the new credential. */} + + { + setShowNewPassword(!showNewPassword); + }, + ), + }, + }} + /> + {formik.errors.newPassword != null && ( + + {formik.errors.newPassword} + + )} + + { + setShowConfirmPassword(!showConfirmPassword); + }, + ), + }, + }} + /> + {formik.errors.confirmPassword != null && ( + + {formik.errors.confirmPassword} + + )} + + + + {errorKey != null && ( + + {t(`errors.${errorKey}`)} + + )} + +
+ )} +
+ ); +} diff --git a/src/app/[locale]/reset-password/lib/reset-password-errors.spec.ts b/src/app/[locale]/reset-password/lib/reset-password-errors.spec.ts new file mode 100644 index 00000000..a1591584 --- /dev/null +++ b/src/app/[locale]/reset-password/lib/reset-password-errors.spec.ts @@ -0,0 +1,45 @@ +import { isLinkUnusable, mapResetPasswordError } from './reset-password-errors'; + +describe('mapResetPasswordError', () => { + it.each([ + ['auth/expired-action-code', 'expiredCode'], + ['auth/invalid-action-code', 'invalidCode'], + ['auth/user-disabled', 'userDisabled'], + ['auth/user-not-found', 'userNotFound'], + ['auth/weak-password', 'weakPassword'], + ['auth/too-many-requests', 'tooManyRequests'], + ['auth/network-request-failed', 'networkError'], + ])('maps %s to %s', (code, expected) => { + expect(mapResetPasswordError({ code })).toBe(expected); + }); + + it('falls back to a generic message for unknown Firebase codes', () => { + expect(mapResetPasswordError({ code: 'auth/internal-error' })).toBe( + 'generic', + ); + }); + + it('falls back for values that are not Firebase errors', () => { + expect(mapResetPasswordError(new Error('boom'))).toBe('generic'); + expect(mapResetPasswordError(undefined)).toBe('generic'); + expect(mapResetPasswordError(null)).toBe('generic'); + expect(mapResetPasswordError('auth/expired-action-code')).toBe('generic'); + expect(mapResetPasswordError({ code: 42 })).toBe('generic'); + }); +}); + +describe('isLinkUnusable', () => { + it('marks spent or rejected links as unusable', () => { + expect(isLinkUnusable('expiredCode')).toBe(true); + expect(isLinkUnusable('invalidCode')).toBe(true); + expect(isLinkUnusable('userDisabled')).toBe(true); + expect(isLinkUnusable('userNotFound')).toBe(true); + }); + + it('keeps the form retryable for transient failures', () => { + expect(isLinkUnusable('weakPassword')).toBe(false); + expect(isLinkUnusable('tooManyRequests')).toBe(false); + expect(isLinkUnusable('networkError')).toBe(false); + expect(isLinkUnusable('generic')).toBe(false); + }); +}); diff --git a/src/app/[locale]/reset-password/lib/reset-password-errors.ts b/src/app/[locale]/reset-password/lib/reset-password-errors.ts new file mode 100644 index 00000000..3cd93c86 --- /dev/null +++ b/src/app/[locale]/reset-password/lib/reset-password-errors.ts @@ -0,0 +1,60 @@ +/** + * Maps Firebase auth error codes raised by `verifyPasswordResetCode` and + * `confirmPasswordReset` onto translation keys under the `resetPassword.errors` + * namespace. + * + * Pure so it can be unit tested without rendering or a Firebase app. + * + * @see https://firebase.google.com/docs/reference/js/auth#autherrorcodes + */ + +export type ResetPasswordErrorKey = + | 'expiredCode' + | 'invalidCode' + | 'userDisabled' + | 'userNotFound' + | 'weakPassword' + | 'tooManyRequests' + | 'networkError' + | 'generic'; + +const ERROR_CODE_TO_KEY: Record = { + 'auth/expired-action-code': 'expiredCode', + 'auth/invalid-action-code': 'invalidCode', + 'auth/user-disabled': 'userDisabled', + 'auth/user-not-found': 'userNotFound', + 'auth/weak-password': 'weakPassword', + 'auth/too-many-requests': 'tooManyRequests', + 'auth/network-request-failed': 'networkError', +}; + +/** + * Errors that mean the link itself is spent: retrying the form cannot help, so + * the user has to request a fresh reset email. + */ +const UNUSABLE_LINK_KEYS = new Set([ + 'expiredCode', + 'invalidCode', + 'userDisabled', + 'userNotFound', +]); + +function getErrorCode(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null || !('code' in error)) { + return undefined; + } + const { code } = error as { code: unknown }; + return typeof code === 'string' ? code : undefined; +} + +export function mapResetPasswordError(error: unknown): ResetPasswordErrorKey { + const code = getErrorCode(error); + if (code === undefined) { + return 'generic'; + } + return ERROR_CODE_TO_KEY[code] ?? 'generic'; +} + +export function isLinkUnusable(key: ResetPasswordErrorKey): boolean { + return UNUSABLE_LINK_KEYS.has(key); +} diff --git a/src/app/[locale]/reset-password/page.tsx b/src/app/[locale]/reset-password/page.tsx new file mode 100644 index 00000000..cf68378a --- /dev/null +++ b/src/app/[locale]/reset-password/page.tsx @@ -0,0 +1,42 @@ +import { type ReactElement } from 'react'; +import { type Metadata } from 'next'; +import { setRequestLocale } from 'next-intl/server'; +import ResetPassword from './ResetPassword'; + +export const metadata: Metadata = { + title: 'Reset Password | MobilityDatabase', + description: + 'Choose a new password for your Mobility Database account using the link sent to your email.', + robots: { + index: false, + follow: false, + googleBot: { + index: false, + follow: false, + 'max-image-preview': 'none', + 'max-snippet': -1, + 'max-video-preview': -1, + }, + }, +}; + +interface PageProps { + params: Promise<{ locale: string }>; + searchParams: Promise<{ oobCode?: string }>; +} + +/** + * Completes a Firebase password reset. Reached from /auth/action, which is the + * URL configured in the Firebase console for account emails. + */ +export default async function ResetPasswordPage({ + params, + searchParams, +}: PageProps): Promise { + const { locale } = await params; + const { oobCode } = await searchParams; + + setRequestLocale(locale); + + return ; +}