Skip to content
Merged
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
40 changes: 40 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions src/app/[locale]/auth/action/AuthActionError.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ContentBox
title=''
sx={{
display: 'flex',
justifyContent: 'center',
backgroundColor: theme.vars.palette.background.paper,
maxWidth: theme.breakpoints.values.sm,
mx: 'auto',
mt: 6,
}}
>
<Stack spacing={3} alignItems='center' textAlign='center'>
<ErrorOutlineIcon color='error' sx={{ fontSize: 56 }} />
<Stack spacing={1.5}>
<Typography variant='h4' component='h1' sx={{ fontWeight: 700 }}>
{t('errorTitle')}
</Typography>
<Typography variant='body1' color='text.secondary'>
{t('errorDescription')}
</Typography>
</Stack>
<Alert severity='error' variant='outlined' sx={{ width: '100%' }}>
{t(reason)}
</Alert>
<Button component={Link} href='/sign-in' variant='contained'>
{t('backToSignIn')}
</Button>
</Stack>
</ContentBox>
);
}
122 changes: 122 additions & 0 deletions src/app/[locale]/auth/action/lib/auth-actions.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
108 changes: 108 additions & 0 deletions src/app/[locale]/auth/action/lib/auth-actions.ts
Original file line number Diff line number Diff line change
@@ -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<SupportedAuthActionMode, string> = {
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;
Comment thread
Alessandro100 marked this conversation as resolved.
}

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),
};
}
Loading
Loading