-
Notifications
You must be signed in to change notification settings - Fork 1
fix: firebase user email actions changed #244
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Alessandro100
merged 1 commit into
main
from
fix/firebase-email-template-reset-password-action-url
Sep 29, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
||
| 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), | ||
| }; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.