From 7f0f25b5588540a68fa523d14ffc5f6b7e4d637b Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:12:20 +0200 Subject: [PATCH] feat(account): self-service login email change with double confirmation (#2017) * feat(account): self-service login email change with double confirmation New POST /api/account/email requests the change via the user session so Supabase's AAL2 guard applies, and the account settings page gets an email row with pending-confirmation state. Confirmation mails (both addresses) and the /auth/callback email_change verification already existed; this wires the missing initiation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018sbGMZQE5W7KfSVFjK7E4p * feat(account): map email_exists to a 409 with Swedish copy Changing to an address that already has an account is refused by GoTrue (addresses are unique per auth user); surface that as a clear conflict instead of the generic fallback. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018sbGMZQE5W7KfSVFjK7E4p * fix(account): trusted redirect origin + profiles.email sync trigger (skeptic findings) - emailRedirectTo now derives from resolveRequestAppOrigin(): request.url can be an internal origin behind a proxy (dead confirmation links on self-hosted) and auth links must not follow attacker-chosen hosts; registered white-label hosts keep their brand. - New migration 20260828191950: sync_profile_email trigger mirrors auth.users.email changes into profiles.email (member lists, notification recipients, AGI/KU contact, invite dedup all read profiles.email), plus a backfill for already-diverged rows. pg-real test included. - Save button disabled while the same address awaits confirmation (no rate-limit re-fires); GoTrue's 'error sending email change email' now maps to the Swedish SMTP guidance. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018sbGMZQE5W7KfSVFjK7E4p * fix(account): idempotent repeat request for the pending address CodeRabbit follow-up: a second POST for the address already awaiting confirmation now returns the pending state without another GoTrue round trip (no duplicate confirmation mails, no rate-limit burn). Claims-mapped sessions lack new_email; GoTrue's send rate limit remains the backstop. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018sbGMZQE5W7KfSVFjK7E4p --------- Co-authored-by: Claude Fable 5 --- app/api/account/email/__tests__/route.test.ts | 173 ++++++++++++++++++ app/api/account/email/route.ts | 102 +++++++++++ .../sections/AccountSettingsContent.tsx | 84 +++++++++ lib/errors/get-error-message.ts | 2 +- messages/en.json | 6 + messages/sv.json | 6 + ...ync_profile_email_on_auth_email_change.sql | 34 ++++ tests/pg/profile-email-sync.pg.test.ts | 87 +++++++++ 8 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 app/api/account/email/__tests__/route.test.ts create mode 100644 app/api/account/email/route.ts create mode 100644 supabase/migrations/20260828191950_sync_profile_email_on_auth_email_change.sql create mode 100644 tests/pg/profile-email-sync.pg.test.ts diff --git a/app/api/account/email/__tests__/route.test.ts b/app/api/account/email/__tests__/route.test.ts new file mode 100644 index 00000000..f2ef1266 --- /dev/null +++ b/app/api/account/email/__tests__/route.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +import { POST } from '../route' + +function mockUserClient(opts: { + user: { id: string; email?: string } | null + updateUserError?: { message: string; status?: number; code?: string } | null +}) { + const updateUser = vi.fn().mockResolvedValue({ + data: {}, + error: opts.updateUserError ?? null, + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const supabase = { auth: { updateUser } } as any + + if (opts.user) { + requireAuthMock.mockResolvedValue({ user: opts.user, supabase, error: null }) + } else { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + } + + return { updateUser } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('POST /api/account/email', () => { + it('returns 401 when unauthenticated', async () => { + mockUserClient({ user: null }) + + const req = createMockRequest('/api/account/email', { + method: 'POST', + body: { email: 'new@testbrand.example' }, + }) + const { status } = await parseJsonResponse(await POST(req)) + expect(status).toBe(401) + }) + + it('returns 400 for an invalid email', async () => { + const { updateUser } = mockUserClient({ + user: { id: 'user-1', email: 'old@testbrand.example' }, + }) + + const req = createMockRequest('/api/account/email', { + method: 'POST', + body: { email: 'not-an-email' }, + }) + const { status } = await parseJsonResponse(await POST(req)) + expect(status).toBe(400) + expect(updateUser).not.toHaveBeenCalled() + }) + + it('returns 400 when the new email equals the current one (case-insensitive)', async () => { + const { updateUser } = mockUserClient({ + user: { id: 'user-1', email: 'Old@Testbrand.example' }, + }) + + const req = createMockRequest('/api/account/email', { + method: 'POST', + body: { email: 'old@testbrand.example' }, + }) + const { status, body } = await parseJsonResponse<{ error?: string }>( + await POST(req), + ) + expect(status).toBe(400) + expect(body.error).toBe('Det är redan din e-postadress.') + expect(updateUser).not.toHaveBeenCalled() + }) + + it('requests the change via the user session with a callback redirect', async () => { + const { updateUser } = mockUserClient({ + user: { id: 'user-1', email: 'old@testbrand.example' }, + }) + + const req = createMockRequest('/api/account/email', { + method: 'POST', + body: { email: 'New@Testbrand.example' }, + }) + const { status, body } = await parseJsonResponse<{ + data?: { ok: boolean; pending_email: string } + }>(await POST(req)) + + expect(status).toBe(200) + expect(body.data?.ok).toBe(true) + // Normalized to lowercase before it reaches Supabase. + expect(body.data?.pending_email).toBe('new@testbrand.example') + expect(updateUser).toHaveBeenCalledTimes(1) + const [attrs, options] = updateUser.mock.calls[0] + expect(attrs).toEqual({ email: 'new@testbrand.example' }) + expect(String(options.emailRedirectTo)).toMatch(/\/auth\/callback$/) + }) + + it('short-circuits a repeat request for the already-pending address', async () => { + const { updateUser } = mockUserClient({ + user: { + id: 'user-1', + email: 'old@testbrand.example', + new_email: 'pending@testbrand.example', + } as { id: string; email?: string }, + }) + + const req = createMockRequest('/api/account/email', { + method: 'POST', + body: { email: 'Pending@Testbrand.example' }, + }) + const { status, body } = await parseJsonResponse<{ + data?: { ok: boolean; pending_email: string } + }>(await POST(req)) + + expect(status).toBe(200) + expect(body.data?.pending_email).toBe('pending@testbrand.example') + expect(updateUser).not.toHaveBeenCalled() + }) + + it('returns 409 when the address already belongs to another account', async () => { + mockUserClient({ + user: { id: 'user-1', email: 'old@testbrand.example' }, + updateUserError: { + message: 'A user with this email address has already been registered', + status: 422, + code: 'email_exists', + }, + }) + + const req = createMockRequest('/api/account/email', { + method: 'POST', + body: { email: 'taken@testbrand.example' }, + }) + const { status, body } = await parseJsonResponse<{ error?: string }>( + await POST(req), + ) + + expect(status).toBe(409) + expect(body.error).toBe('E-postadressen används redan av ett annat konto.') + }) + + it('returns 400 and surfaces the AAL2 error when Supabase rejects the update', async () => { + const { updateUser } = mockUserClient({ + user: { id: 'user-1', email: 'old@testbrand.example' }, + updateUserError: { + message: + 'AAL2 session is required to update email or password when MFA is enabled', + status: 422, + }, + }) + + const req = createMockRequest('/api/account/email', { + method: 'POST', + body: { email: 'new@testbrand.example' }, + }) + const { status, body } = await parseJsonResponse<{ error?: string }>( + await POST(req), + ) + + expect(status).toBe(400) + expect(updateUser).toHaveBeenCalled() + expect(body.error).toContain('AAL2') + }) +}) diff --git a/app/api/account/email/route.ts b/app/api/account/email/route.ts new file mode 100644 index 00000000..3490efc8 --- /dev/null +++ b/app/api/account/email/route.ts @@ -0,0 +1,102 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { requireAuth } from '@/lib/auth/require-auth' +import { resolveRequestAppOrigin } from '@/lib/domains/trusted-app-origin' +import { validateBody } from '@/lib/api/validate' +import { createLogger } from '@/lib/logger' +import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' + +const log = createLogger('api/account/email') + +const ChangeEmailSchema = z.object({ + email: z.string().trim().toLowerCase().max(320).pipe(z.string().email()), +}) + +/** + * POST /api/account/email + * + * Server-routed login-email change. Always writes via the USER session so + * Supabase's AAL2 guard fires: an email change is a credential rotation, and + * a stolen AAL1 cookie must not be able to move the account to another + * mailbox. (Contrast app/api/account/password/route.ts, whose first-time-set + * path may bypass AAL2 because there is no existing credential to protect; + * an email change always has one.) + * + * Nothing changes immediately: with secure email change enabled, Supabase + * sends `email_change_current` to the old address and `email_change` to the + * new one (templates in lib/email/auth-templates.ts via the send-email hook), + * and the address flips only after confirmation. The links verify through + * /auth/callback, which already handles type=email_change. + * + * The account itself is keyed by user id everywhere (company_members, + * user_preferences, ...), so a confirmed change moves nothing but the login + * identifier and contact address. profiles.email mirrors auth.users.email via + * the sync_profile_email trigger (migration 20260828191950), so member lists, + * notification recipients, and AGI/KU contact fields follow the change. + */ +export async function POST(request: Request) { + const { user, supabase, error: authError } = await requireAuth() + if (authError) return authError + + const result = await validateBody(request, ChangeEmailSchema) + if (!result.success) return result.response + const { email } = result.data + + if (user.email && email === user.email.toLowerCase()) { + return NextResponse.json( + { error: 'Det är redan din e-postadress.' }, + { status: 400 }, + ) + } + + // Re-requesting the address that is already awaiting confirmation is a + // no-op success rather than another GoTrue round trip (which would re-send + // both confirmation mails and eat into the send rate limit). new_email is + // absent on the claims-mapped fast path; then GoTrue's own rate limit is + // the backstop. + if (user.new_email && email === user.new_email.toLowerCase()) { + return NextResponse.json({ data: { ok: true, pending_email: email } }) + } + + // Trusted-origin resolution, not request.url: behind a proxy request.url + // can be an internal origin (dead confirmation links on self-hosted), and + // auth links may never follow an attacker-chosen host. Registered + // white-label hosts pass through so the mail carries the right brand. + const origin = resolveRequestAppOrigin(request) + const { error: updateError } = await supabase.auth.updateUser( + { email }, + { emailRedirectTo: `${origin}/auth/callback` }, + ) + + if (updateError) { + log.warn('email change request failed', { + userId: user.id, + code: updateError.code, + status: updateError.status, + }) + // Addresses are unique per auth user: a change to an already-registered + // address is refused by GoTrue, never merged. Accounts are consolidated + // via company invitations, not email changes. + if ( + updateError.code === 'email_exists' || + /already.*registered/i.test(updateError.message ?? '') + ) { + return NextResponse.json( + { error: 'E-postadressen används redan av ett annat konto.' }, + { status: 409 }, + ) + } + return NextResponse.json( + { + error: + getUserErrorMessage(updateError) || + 'Kunde inte begära e-poständring. Försök igen.', + }, + { status: 400 }, + ) + } + + log.info('email change requested', { userId: user.id }) + + return NextResponse.json({ data: { ok: true, pending_email: email } }) +} diff --git a/components/settings/sections/AccountSettingsContent.tsx b/components/settings/sections/AccountSettingsContent.tsx index 9f592abe..265ce730 100644 --- a/components/settings/sections/AccountSettingsContent.tsx +++ b/components/settings/sections/AccountSettingsContent.tsx @@ -23,6 +23,7 @@ import { import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import { useSettings } from '@/components/settings/useSettings' import { resetAnalyticsIdentity } from '@/lib/analytics/reset' +import { getErrorMessage } from '@/lib/errors/get-error-message' import { useToast } from '@/components/ui/use-toast' import { SUPPORTED_LOCALES, type Locale } from '@/i18n/config' import { PalettePicker } from '@/components/settings/PalettePicker' @@ -48,6 +49,10 @@ export function AccountSettingsContent() { const [initialName, setInitialName] = useState('') const [nameLoading, setNameLoading] = useState(true) const [savingName, setSavingName] = useState(false) + const [email, setEmail] = useState('') + const [currentEmail, setCurrentEmail] = useState('') + const [savingEmail, setSavingEmail] = useState(false) + const [pendingEmail, setPendingEmail] = useState(null) useEffect(() => { setMounted(true) }, []) @@ -58,6 +63,12 @@ export function AccountSettingsContent() { ;(async () => { const { data: { user } } = await supabase.auth.getUser() if (!user) { if (active) setNameLoading(false); return } + if (active && user.email) { + setEmail(user.email) + setCurrentEmail(user.email) + } + // A change already awaiting confirmation survives a page reload. + if (active && user.new_email) setPendingEmail(user.new_email.toLowerCase()) const { data } = await supabase .from('profiles') .select('full_name') @@ -94,6 +105,44 @@ export function AccountSettingsContent() { } } + async function handleSaveEmail() { + const trimmed = email.trim().toLowerCase() + if (!trimmed || trimmed === currentEmail.toLowerCase() || savingEmail) return + setSavingEmail(true) + try { + const res = await fetch('/api/account/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: trimmed }), + }) + const json = await res.json().catch(() => null) + if (!res.ok) { + toast({ + title: tSettings('email_change_failed'), + description: getErrorMessage(json, { + statusCode: res.status, + locale: activeLocale, + }), + variant: 'destructive', + }) + return + } + setPendingEmail(trimmed) + toast({ + title: tSettings('email_change_requested'), + description: tSettings('email_change_requested_help'), + }) + } catch (err) { + toast({ + title: tSettings('email_change_failed'), + description: getErrorMessage(err, { locale: activeLocale }), + variant: 'destructive', + }) + } finally { + setSavingEmail(false) + } + } + async function handleLogout() { resetAnalyticsIdentity() await supabase.auth.signOut() @@ -167,6 +216,41 @@ export function AccountSettingsContent() { + + setEmail(e.target.value)} + disabled={!currentEmail || savingEmail} + maxLength={320} + /> + + + + + {mounted && ( { + const id = await insertAuthUser() + createdUsers.push(id) + return id +} + +afterAll(async () => { + if (createdUsers.length > 0) { + await getPool().query(`DELETE FROM auth.users WHERE id = ANY($1::uuid[])`, [ + createdUsers, + ]) + } +}) + +describe('sync_profile_email trigger', () => { + it('mirrors an auth.users email change into profiles.email', async () => { + const userId = await seedUser() + + const before = await getPool().query( + `SELECT email FROM public.profiles WHERE id = $1`, + [userId], + ) + expect(before.rows[0].email).toBe(`pg-real-${userId}@test.invalid`) + + await getPool().query(`UPDATE auth.users SET email = $2 WHERE id = $1`, [ + userId, + `changed-${userId}@test.invalid`, + ]) + + const after = await getPool().query( + `SELECT email FROM public.profiles WHERE id = $1`, + [userId], + ) + expect(after.rows[0].email).toBe(`changed-${userId}@test.invalid`) + }) + + it('does not clobber profiles on unrelated auth.users updates', async () => { + const userId = await seedUser() + + // A user-managed profile email divergence must survive updates that do + // not touch auth.users.email (the trigger fires on UPDATE OF email only). + await getPool().query( + `UPDATE public.profiles SET email = $2 WHERE id = $1`, + [userId, `manual-${userId}@test.invalid`], + ) + await getPool().query( + `UPDATE auth.users SET updated_at = now() WHERE id = $1`, + [userId], + ) + + const res = await getPool().query( + `SELECT email FROM public.profiles WHERE id = $1`, + [userId], + ) + expect(res.rows[0].email).toBe(`manual-${userId}@test.invalid`) + }) + + it('syncs even when profiles.email was already divergent', async () => { + const userId = await seedUser() + + await getPool().query( + `UPDATE public.profiles SET email = $2 WHERE id = $1`, + [userId, `stale-${userId}@test.invalid`], + ) + await getPool().query(`UPDATE auth.users SET email = $2 WHERE id = $1`, [ + userId, + `fresh-${userId}@test.invalid`, + ]) + + const res = await getPool().query( + `SELECT email FROM public.profiles WHERE id = $1`, + [userId], + ) + expect(res.rows[0].email).toBe(`fresh-${userId}@test.invalid`) + }) +})