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')}
+
+
+
+
+
+ )}
+
+ );
+}
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 ;
+}