* 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>
100 lines
3.2 KiB
TypeScript
100 lines
3.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { NextRequest } from 'next/server'
|
|
|
|
// The email_change branch returns before any of the invite/team/landing
|
|
// machinery runs; these mocks only keep the module importable in the test
|
|
// environment.
|
|
vi.mock('@/lib/auth/invite-tokens', () => ({ hashInviteToken: vi.fn() }))
|
|
vi.mock('@/lib/auth/consume-invite-cookie', () => ({
|
|
INVITE_COOKIE_NAME: 'gnubok-invite-token',
|
|
}))
|
|
vi.mock('@/lib/company/landing-server', () => ({
|
|
resolveLandingDestination: vi.fn().mockResolvedValue('/'),
|
|
}))
|
|
vi.mock('@/lib/company/pending-invites', () => ({
|
|
acceptPendingTeamInviteByToken: vi.fn(),
|
|
}))
|
|
|
|
const verifyOtp = vi.fn()
|
|
vi.mock('@supabase/ssr', () => ({
|
|
createServerClient: () => ({
|
|
auth: {
|
|
verifyOtp: (...args: unknown[]) => verifyOtp(...args),
|
|
exchangeCodeForSession: vi.fn(),
|
|
getUser: vi.fn().mockResolvedValue({ data: { user: null } }),
|
|
},
|
|
}),
|
|
}))
|
|
|
|
import { GET } from '../route'
|
|
|
|
function makeRequest(params: Record<string, string>) {
|
|
const url = new URL('https://app.testbrand.example/auth/callback')
|
|
for (const [key, value] of Object.entries(params)) {
|
|
url.searchParams.set(key, value)
|
|
}
|
|
return new NextRequest(url)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('GET /auth/callback type=email_change', () => {
|
|
it('redirects a first confirmation (no session) to the partial status page', async () => {
|
|
verifyOtp.mockResolvedValue({
|
|
data: { user: null, session: null },
|
|
error: null,
|
|
})
|
|
|
|
const res = await GET(makeRequest({ token_hash: 'th', type: 'email_change' }))
|
|
|
|
expect(res.headers.get('location')).toBe(
|
|
'https://app.testbrand.example/auth/email-change?status=partial',
|
|
)
|
|
expect(verifyOtp).toHaveBeenCalledWith({
|
|
token_hash: 'th',
|
|
type: 'email_change',
|
|
})
|
|
})
|
|
|
|
it('redirects the completing confirmation (session minted) to the done status page', async () => {
|
|
verifyOtp.mockResolvedValue({
|
|
data: { user: { id: 'u1' }, session: { access_token: 'at' } },
|
|
error: null,
|
|
})
|
|
|
|
const res = await GET(makeRequest({ token_hash: 'th', type: 'email_change' }))
|
|
|
|
expect(res.headers.get('location')).toBe(
|
|
'https://app.testbrand.example/auth/email-change?status=done',
|
|
)
|
|
})
|
|
|
|
it('redirects an invalid or expired link to the failed status page', async () => {
|
|
verifyOtp.mockResolvedValue({
|
|
data: { user: null, session: null },
|
|
error: { message: 'Email link is invalid or has expired' },
|
|
})
|
|
|
|
const res = await GET(makeRequest({ token_hash: 'th', type: 'email_change' }))
|
|
|
|
expect(res.headers.get('location')).toBe(
|
|
'https://app.testbrand.example/auth/email-change?status=failed',
|
|
)
|
|
})
|
|
|
|
it('keeps the generic login-error redirect for other failed types', async () => {
|
|
verifyOtp.mockResolvedValue({
|
|
data: { user: null, session: null },
|
|
error: { message: 'Email link is invalid or has expired' },
|
|
})
|
|
|
|
const res = await GET(makeRequest({ token_hash: 'th', type: 'signup' }))
|
|
|
|
const location = res.headers.get('location') ?? ''
|
|
expect(location).toContain('/login?error=auth_error')
|
|
expect(location).not.toContain('/auth/email-change')
|
|
})
|
|
})
|