fix(auth): email-change recovery re-send and confirmation feedback (#2034)
* fix(auth): email-change recovery re-send and confirmation feedback A half-completed secure email change was a dead end: the pending-address short-circuit in /api/account/email swallowed every retry without re-sending mails, so once the confirmation links expired the user could never recover, and confirmation clicks landed on the dashboard with no feedback at all. - /api/account/email: only short-circuit a repeat request while the pending mails are fresh (30 min); a stale pending change falls through to GoTrue, which restarts the change and re-sends both mails - /auth/callback: type=email_change now redirects to a status page (/auth/email-change) that says whether one click remains, the change is complete, or the link was dead, instead of landing silently - auth mail templates: both email-change mails explain that two mails are sent and both links must be clicked - settings: the save button re-enables for the pending address as Skicka igen, so users can trigger the re-send themselves Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sbGMZQE5W7KfSVFjK7E4p * fix(auth): exempt email-change confirmations from the authenticated /auth bounce (skeptic findings) - middleware: let /auth/email-change and /auth/callback?type=email_change through for authenticated users; the bounce to / swallowed confirmation clicks before verifyOtp ran (pre-existing since #2017) - email-change done page resolves the WL-14 landing destination for the CTA - /api/account/email returns resent flag; settings toast says mails were already sent instead of claiming a fresh send Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sbGMZQE5W7KfSVFjK7E4p --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9f8fa1b692
commit
341d61131a
@@ -49,6 +49,18 @@ describe('buildAuthEmail', () => {
|
||||
expect(mail.text).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('tells the user both mails must be clicked for an email change', () => {
|
||||
for (const actionType of ['email_change', 'email_change_current'] as const) {
|
||||
const mail = buildAuthEmail({
|
||||
actionType,
|
||||
appName: 'Siffra',
|
||||
actionUrl: URL_EXAMPLE,
|
||||
})
|
||||
expect(mail.text).toContain('två mail')
|
||||
expect(mail.text).toContain('länken i båda')
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the reauthentication code without a link', () => {
|
||||
const mail = buildAuthEmail({
|
||||
actionType: 'reauthentication',
|
||||
|
||||
@@ -60,14 +60,14 @@ const CONTENT: Record<AuthEmailActionType, AuthEmailContent> = {
|
||||
subject: 'Bekräfta din nya e-postadress',
|
||||
heading: 'Bekräfta din nya e-postadress',
|
||||
body: (appName) =>
|
||||
`Klicka på knappen nedan för att bekräfta din nya e-postadress hos ${appName}.`,
|
||||
`Klicka på knappen nedan för att bekräfta din nya e-postadress hos ${appName}. Av säkerhetsskäl skickas två mail, ett till din nya adress och ett till din nuvarande. Bytet slutförs först när du klickat på länken i båda.`,
|
||||
cta: 'Bekräfta ny e-postadress',
|
||||
},
|
||||
email_change_current: {
|
||||
subject: 'Godkänn ändrad e-postadress',
|
||||
heading: 'Godkänn ändrad e-postadress',
|
||||
body: (appName) =>
|
||||
`En ändring av e-postadressen för ditt konto hos ${appName} har begärts. Klicka på knappen nedan för att godkänna ändringen från din nuvarande adress.`,
|
||||
`En ändring av e-postadressen för ditt konto hos ${appName} har begärts. Klicka på knappen nedan för att godkänna ändringen från din nuvarande adress. Av säkerhetsskäl skickas två mail, ett till din nuvarande adress och ett till din nya. Bytet slutförs först när du klickat på länken i båda.`,
|
||||
cta: 'Godkänn ändringen',
|
||||
},
|
||||
reauthentication: {
|
||||
|
||||
@@ -525,6 +525,28 @@ describe('updateSession redirect destinations', () => {
|
||||
expect(locationOf(await run('/sandbox?next=%2Fsettings%2Ftax'))).toBe(`${ORIGIN}/`)
|
||||
expect(locationOf(await run('/auth/callback?next=%2Fsettings%2Ftax'))).toBe(`${ORIGIN}/`)
|
||||
})
|
||||
|
||||
// Email-change confirmation links are usually clicked while still logged
|
||||
// in (the change starts in settings), and the completing verify mints a
|
||||
// session before the status page renders. Bouncing either request off the
|
||||
// /auth prefix silently swallowed the confirmation.
|
||||
it('lets an authenticated email-change confirmation reach the callback', async () => {
|
||||
const response = await run('/auth/callback?token_hash=abc&type=email_change')
|
||||
expect(response.status).not.toBe(307)
|
||||
expect(locationOf(response)).toBeNull()
|
||||
})
|
||||
|
||||
it('lets an authenticated user see the email-change status page', async () => {
|
||||
const response = await run('/auth/email-change?status=done')
|
||||
expect(response.status).not.toBe(307)
|
||||
expect(locationOf(response)).toBeNull()
|
||||
})
|
||||
|
||||
it('still bounces other authenticated token types off the callback', async () => {
|
||||
expect(locationOf(await run('/auth/callback?token_hash=abc&type=signup'))).toBe(
|
||||
`${ORIGIN}/`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Sites 2 and 3: MFA step-up and forced enrollment ──────────────────
|
||||
|
||||
@@ -282,6 +282,21 @@ async function updateSessionInner(
|
||||
return supabaseResponse
|
||||
}
|
||||
|
||||
// Email-change confirmations are reachable in both auth states, for the
|
||||
// same reason as /reset-password above. The change starts in settings, so
|
||||
// the confirmation links are usually clicked while still logged in, and the
|
||||
// completing verify mints a fresh session itself. Bouncing authenticated
|
||||
// requests off these paths (a) dropped the confirmation click before
|
||||
// verifyOtp could consume the token, so the change never completed, and
|
||||
// (b) hid the /auth/email-change status page in exactly the success case.
|
||||
if (
|
||||
pathname.startsWith('/auth/email-change') ||
|
||||
(pathname.startsWith('/auth/callback') &&
|
||||
request.nextUrl.searchParams.get('type') === 'email_change')
|
||||
) {
|
||||
return supabaseResponse
|
||||
}
|
||||
|
||||
// Public agent-discovery + API docs surfaces. /llms.txt and /llms-full.txt
|
||||
// exist FOR anonymous consumers (the llms.txt convention targets logged-out
|
||||
// crawlers and IDE agents), and /docs is the public API documentation the
|
||||
|
||||
Reference in New Issue
Block a user