From 0ce05372bf85bd3f443e0df8326ece6b4374100c Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Sun, 26 Apr 2026 07:47:25 -0600 Subject: [PATCH] Implement email change --- app/(app)/profile/ProfilePageClient.tsx | 88 ++++++- .../profile/_components/ProfilePage.view.tsx | 86 +++++- app/api/user/email-change/request/route.ts | 133 ++++++++++ app/api/user/email-change/verify/route.ts | 172 ++++++++++++ docs/guides/backend-linear-tickets.md | 8 +- docs/guides/backend-roadmap.md | 7 +- lib/create/api.ts | 33 +++ lib/server/mail.ts | 27 ++ lib/server/session.ts | 37 +++ .../validation/userEmailChangeSchemas.ts | 15 ++ messages/en/pages/profile.json | 13 +- .../migration.sql | 23 ++ prisma/schema.prisma | 22 +- .../unit/userEmailChangeRequestRoute.test.ts | 176 +++++++++++++ tests/unit/userEmailChangeVerifyRoute.test.ts | 245 ++++++++++++++++++ 15 files changed, 1072 insertions(+), 13 deletions(-) create mode 100644 app/api/user/email-change/request/route.ts create mode 100644 app/api/user/email-change/verify/route.ts create mode 100644 lib/server/validation/userEmailChangeSchemas.ts create mode 100644 prisma/migrations/20260426000815_add_email_change_token/migration.sql create mode 100644 tests/unit/userEmailChangeRequestRoute.test.ts create mode 100644 tests/unit/userEmailChangeVerifyRoute.test.ts diff --git a/app/(app)/profile/ProfilePageClient.tsx b/app/(app)/profile/ProfilePageClient.tsx index a60f7e4..97c068e 100644 --- a/app/(app)/profile/ProfilePageClient.tsx +++ b/app/(app)/profile/ProfilePageClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuthModal } from "../../contexts/AuthModalContext"; import { useTranslation } from "../../contexts/MessagesContext"; @@ -13,6 +13,7 @@ import { fetchMyPublishedRules, fetchServerDraftForProfile, logout, + requestEmailChange, type MyPublishedRule, } from "../../../lib/create/api"; import { @@ -55,6 +56,16 @@ export default function ProfilePageClient() { const [accountDeleteOpen, setAccountDeleteOpen] = useState(false); const [accountDeleteBusy, setAccountDeleteBusy] = useState(false); const [actionError, setActionError] = useState(null); + const [emailChangeOpen, setEmailChangeOpen] = useState(false); + const [emailChangeInput, setEmailChangeInput] = useState(""); + const [emailChangeBusy, setEmailChangeBusy] = useState(false); + const [emailChangeModalError, setEmailChangeModalError] = useState< + string | null + >(null); + const [profileSuccessMessage, setProfileSuccessMessage] = useState< + string | null + >(null); + const emailChangeQueryHandledRef = useRef(false); const load = useCallback(async () => { setActionError(null); @@ -85,6 +96,72 @@ export default function ProfilePageClient() { void load(); }, [load]); + useEffect(() => { + if (emailChangeQueryHandledRef.current) return; + if (typeof window === "undefined") return; + const search = window.location.search; + if (!search) return; + const params = new URLSearchParams(search); + const ok = params.get("email_change"); + const err = params.get("error"); + if (ok !== "ok" && !err?.startsWith("email_change_")) return; + + emailChangeQueryHandledRef.current = true; + + if (ok === "ok") { + setProfileSuccessMessage(t("emailChangeSuccess")); + void load().then(() => { + router.refresh(); + }); + } else if (err === "email_change_expired") { + setActionError(t("emailChangeVerifyExpired")); + } else if (err === "email_change_invalid") { + setActionError(t("emailChangeVerifyInvalid")); + } else if (err === "email_change_taken") { + setActionError(t("emailChangeVerifyTaken")); + } else if (err === "email_change_server") { + setActionError(t("actionError")); + } + + router.replace("/profile", { scroll: false }); + }, [load, router, t]); + + const handleOpenEmailChange = useCallback(() => { + if (!user) return; + setActionError(null); + setProfileSuccessMessage(null); + setEmailChangeModalError(null); + setEmailChangeInput(user.email); + setEmailChangeOpen(true); + }, [user]); + + const handleCloseEmailChange = useCallback(() => { + if (emailChangeBusy) return; + setEmailChangeOpen(false); + }, [emailChangeBusy]); + + const handleSubmitEmailChange = useCallback(async () => { + const trimmed = emailChangeInput.trim(); + if (!trimmed || emailChangeBusy) return; + setEmailChangeModalError(null); + setEmailChangeBusy(true); + const res = await requestEmailChange(trimmed); + setEmailChangeBusy(false); + if (res.ok === false) { + if (res.retryAfterMs != null && res.retryAfterMs > 0) { + const sec = Math.max(1, Math.ceil(res.retryAfterMs / 1000)); + setEmailChangeModalError( + t("emailChangeRateLimited").replace(/\{\{seconds\}\}/g, String(sec)), + ); + } else { + setEmailChangeModalError(res.error); + } + } else { + setEmailChangeOpen(false); + setProfileSuccessMessage(t("emailChangeRequestSent")); + } + }, [emailChangeBusy, emailChangeInput, t]); + const handleSignOut = useCallback(async () => { setActionError(null); await logout(); @@ -236,6 +313,15 @@ export default function ProfilePageClient() { accountDeleteOpen={accountDeleteOpen} accountDeleteBusy={accountDeleteBusy} actionError={actionError} + profileSuccessMessage={profileSuccessMessage} + emailChangeOpen={emailChangeOpen} + emailChangeValue={emailChangeInput} + onEmailChangeValueChange={(value) => setEmailChangeInput(value)} + emailChangeBusy={emailChangeBusy} + emailChangeModalError={emailChangeModalError} + onOpenEmailChange={handleOpenEmailChange} + onCloseEmailChange={handleCloseEmailChange} + onSubmitEmailChange={handleSubmitEmailChange} onSignOut={handleSignOut} onDeleteRule={handleRequestDeleteRule} onCloseDeleteRule={handleCloseDeleteRuleDialog} diff --git a/app/(app)/profile/_components/ProfilePage.view.tsx b/app/(app)/profile/_components/ProfilePage.view.tsx index 0c8fb84..9f6790a 100644 --- a/app/(app)/profile/_components/ProfilePage.view.tsx +++ b/app/(app)/profile/_components/ProfilePage.view.tsx @@ -3,6 +3,7 @@ import { useId, useMemo } from "react"; import Button from "../../../components/buttons/Button"; import RuleCard from "../../../components/cards/RuleCard"; +import TextInput from "../../../components/controls/TextInput"; import List from "../../../components/layout/List"; import type { ListItem, ListSize } from "../../../components/layout/List"; import Dialog from "../../../components/modals/Dialog"; @@ -43,6 +44,15 @@ export type ProfilePageViewProps = { accountDeleteOpen: boolean; accountDeleteBusy: boolean; actionError: string | null; + profileSuccessMessage: string | null; + emailChangeOpen: boolean; + emailChangeValue: string; + onEmailChangeValueChange: (value: string) => void; + emailChangeBusy: boolean; + emailChangeModalError: string | null; + onOpenEmailChange: () => void; + onCloseEmailChange: () => void; + onSubmitEmailChange: () => void; onSignOut: () => void; onDeleteRule: (id: string) => void; onCloseDeleteRule: () => void; @@ -156,6 +166,15 @@ export function ProfilePageView({ accountDeleteOpen, accountDeleteBusy, actionError, + profileSuccessMessage, + emailChangeOpen, + emailChangeValue, + onEmailChangeValueChange, + emailChangeBusy, + emailChangeModalError, + onOpenEmailChange, + onCloseEmailChange, + onSubmitEmailChange, onSignOut, onDeleteRule, onCloseDeleteRule, @@ -205,8 +224,8 @@ export function ProfilePageView({ id: "change-email", title: t("optionChangeEmail"), description: "", + onClick: onOpenEmailChange, leadingIcon: "mail", - variant: "muted", showDescription: false, }, { @@ -219,7 +238,7 @@ export function ProfilePageView({ showDescription: false, }, ]; - }, [t, onSignOut, onOpenDeleteAccount]); + }, [t, onSignOut, onOpenDeleteAccount, onOpenEmailChange]); const ruleCardShellClass = "w-full !max-w-full cursor-default !gap-3 !rounded-[12px] shadow-[0_0_48px_rgba(0,0,0,0.1)] lg:!rounded-[24px] lg:shadow-[0_0_24px_rgba(0,0,0,0.1)]"; @@ -258,6 +277,15 @@ export function ProfilePageView({ )} + {profileSuccessMessage ? ( +

+ {profileSuccessMessage} +

+ ) : null} + {actionError ? (

} /> + +

{ + if (!emailChangeBusy) onCloseEmailChange(); + }} + backdropVariant="blurredYellow" + title={t("emailChangeModalTitle")} + description={t("emailChangeModalDescription")} + footer={ + <> + + + + } + > + {emailChangeModalError ? ( +

+ {emailChangeModalError} +

+ ) : null} + onEmailChangeValueChange(e.target.value)} + disabled={emailChangeBusy} + error={Boolean(emailChangeModalError)} + autoComplete="email" + /> +
); } diff --git a/app/api/user/email-change/request/route.ts b/app/api/user/email-change/request/route.ts new file mode 100644 index 0000000..f27cf7b --- /dev/null +++ b/app/api/user/email-change/request/route.ts @@ -0,0 +1,133 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "../../../../../lib/server/db"; +import { + getSessionPepper, + isDatabaseConfigured, +} from "../../../../../lib/server/env"; +import { + hashSessionToken, + newSessionToken, +} from "../../../../../lib/server/hash"; +import { sendEmailChangeEmail } from "../../../../../lib/server/mail"; +import { rateLimitKey } from "../../../../../lib/server/rateLimit"; +import { apiRoute } from "../../../../../lib/server/apiRoute"; +import { logRouteError } from "../../../../../lib/server/requestId"; +import { + dbUnavailable, + errorJson, + rateLimited, + serverMisconfigured, + unauthorized, +} from "../../../../../lib/server/responses"; +import { getSessionUser } from "../../../../../lib/server/session"; +import { readLimitedJson } from "../../../../../lib/server/validation/requestBody"; +import { emailChangeRequestBodySchema } from "../../../../../lib/server/validation/userEmailChangeSchemas"; +import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp"; + +const EMAIL_CHANGE_TTL_MS = 15 * 60 * 1000; +const EMAIL_MIN_INTERVAL_MS = 60 * 1000; +const IP_MIN_INTERVAL_MS = 20 * 1000; +const SCOPE = "user.emailChange.request"; + +export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const user = await getSessionUser(); + if (!user) { + return unauthorized(); + } + + const limited = await readLimitedJson(request); + if (limited.ok === false) { + return limited.response; + } + + const parsed = emailChangeRequestBodySchema.safeParse(limited.value); + if (!parsed.success) { + return jsonFromZodError(parsed.error); + } + + const { newEmail } = parsed.data; + if (newEmail === user.email) { + return errorJson( + "validation_error", + "New email must be different from your current email", + 400, + ); + } + + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + request.headers.get("x-real-ip") ?? + "unknown"; + + const rlEmail = rateLimitKey( + `email-change-email:${newEmail}`, + EMAIL_MIN_INTERVAL_MS, + ); + if (rlEmail.ok === false) { + return rateLimited(rlEmail.retryAfterMs); + } + + const rlIp = rateLimitKey(`email-change-ip:${ip}`, IP_MIN_INTERVAL_MS); + if (rlIp.ok === false) { + return rateLimited(rlIp.retryAfterMs); + } + + const rlUser = rateLimitKey( + `email-change-user:${user.id}`, + EMAIL_MIN_INTERVAL_MS, + ); + if (rlUser.ok === false) { + return rateLimited(rlUser.retryAfterMs); + } + + const existing = await prisma.user.findUnique({ where: { email: newEmail } }); + if (existing && existing.id !== user.id) { + return errorJson( + "validation_error", + "That email is already used by another account", + 400, + { details: { field: "newEmail" } }, + ); + } + + let pepper: string; + try { + pepper = getSessionPepper(); + } catch { + return serverMisconfigured(); + } + + const token = newSessionToken(); + const tokenHash = hashSessionToken(token, pepper); + const expiresAt = new Date(Date.now() + EMAIL_CHANGE_TTL_MS); + + await prisma.emailChangeToken.deleteMany({ where: { userId: user.id } }); + await prisma.emailChangeToken.create({ + data: { + userId: user.id, + newEmail, + tokenHash, + expiresAt, + }, + }); + + const origin = request.nextUrl.origin; + const verifyUrl = `${origin}/api/user/email-change/verify?token=${encodeURIComponent(token)}`; + + try { + await sendEmailChangeEmail(newEmail, verifyUrl); + } catch (err) { + logRouteError(SCOPE, requestId, err, { + phase: "sendEmailChangeEmail", + newEmail, + }); + await prisma.emailChangeToken.deleteMany({ where: { userId: user.id } }); + return errorJson("mail_failed", "Could not send email", 502); + } + + return NextResponse.json({ ok: true }); +}); diff --git a/app/api/user/email-change/verify/route.ts b/app/api/user/email-change/verify/route.ts new file mode 100644 index 0000000..c40399b --- /dev/null +++ b/app/api/user/email-change/verify/route.ts @@ -0,0 +1,172 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "../../../../../lib/server/db"; +import { + getSessionPepper, + isDatabaseConfigured, +} from "../../../../../lib/server/env"; +import { hashSessionToken } from "../../../../../lib/server/hash"; +import { + createSessionForUser, + getValidatedSessionTokenHashForUser, + setSessionCookie, +} from "../../../../../lib/server/session"; +import { dbUnavailable } from "../../../../../lib/server/responses"; +import { + REQUEST_ID_HEADER, + getOrCreateRequestId, + logRouteError, +} from "../../../../../lib/server/requestId"; + +const SCOPE = "user.emailChange.verify"; + +export async function GET(request: NextRequest) { + const requestId = getOrCreateRequestId(request); + + if (!isDatabaseConfigured()) { + const res = dbUnavailable(); + res.headers.set(REQUEST_ID_HEADER, requestId); + return res; + } + + try { + const token = request.nextUrl.searchParams.get("token"); + if (!token || token.length < 10) { + return redirectWithRequestId( + request, + "/profile?error=email_change_invalid", + requestId, + ); + } + + let pepper: string; + try { + pepper = getSessionPepper(); + } catch (err) { + logRouteError(SCOPE, requestId, err, { phase: "getSessionPepper" }); + return redirectWithRequestId( + request, + "/profile?error=email_change_server", + requestId, + ); + } + + const tokenHash = hashSessionToken(token, pepper); + const row = await prisma.emailChangeToken.findUnique({ + where: { tokenHash }, + }); + + if (!row || row.expiresAt < new Date()) { + return redirectWithRequestId( + request, + "/profile?error=email_change_expired", + requestId, + ); + } + + const keepSessionTokenHash = await getValidatedSessionTokenHashForUser( + row.userId, + ); + + try { + await prisma.$transaction(async (tx) => { + const claim = await tx.emailChangeToken.findUnique({ + where: { id: row.id }, + }); + if (!claim || claim.expiresAt < new Date()) { + throw Object.assign(new Error("expired"), { __expired: true }); + } + + const taken = await tx.user.findFirst({ + where: { + email: claim.newEmail, + NOT: { id: claim.userId }, + }, + }); + if (taken) { + await tx.emailChangeToken.delete({ where: { id: claim.id } }); + throw Object.assign(new Error("taken"), { __taken: true }); + } + + await tx.user.update({ + where: { id: claim.userId }, + data: { email: claim.newEmail }, + }); + await tx.emailChangeToken.delete({ where: { id: claim.id } }); + + if (keepSessionTokenHash) { + await tx.session.deleteMany({ + where: { + userId: claim.userId, + tokenHash: { not: keepSessionTokenHash }, + }, + }); + } else { + await tx.session.deleteMany({ + where: { userId: claim.userId }, + }); + } + }); + } catch (err: unknown) { + if ( + err && + typeof err === "object" && + "__taken" in err && + (err as { __taken?: boolean }).__taken + ) { + return redirectWithRequestId( + request, + "/profile?error=email_change_taken", + requestId, + ); + } + if ( + err && + typeof err === "object" && + "__expired" in err && + (err as { __expired?: boolean }).__expired + ) { + return redirectWithRequestId( + request, + "/profile?error=email_change_expired", + requestId, + ); + } + logRouteError(SCOPE, requestId, err, { phase: "transaction" }); + return redirectWithRequestId( + request, + "/profile?error=email_change_server", + requestId, + ); + } + + if (!keepSessionTokenHash) { + const { token: sessionToken, expiresAt } = await createSessionForUser( + row.userId, + ); + await setSessionCookie(sessionToken, expiresAt); + } + + return redirectWithRequestId( + request, + "/profile?email_change=ok", + requestId, + ); + } catch (err) { + logRouteError(SCOPE, requestId, err); + return redirectWithRequestId( + request, + "/profile?error=email_change_server", + requestId, + ); + } +} + +function redirectWithRequestId( + request: NextRequest, + path: string, + requestId: string, +): NextResponse { + const res = NextResponse.redirect(new URL(path, request.url)); + res.headers.set(REQUEST_ID_HEADER, requestId); + return res; +} diff --git a/docs/guides/backend-linear-tickets.md b/docs/guides/backend-linear-tickets.md index 935dc03..be106b6 100644 --- a/docs/guides/backend-linear-tickets.md +++ b/docs/guides/backend-linear-tickets.md @@ -482,10 +482,10 @@ _Section B — Final Review screen `+` button per category:_ **Acceptance criteria:** -- [ ] New email is confirmed **only** after the user completes the link sent to that inbox; then `User.email` updates. -- [ ] Duplicate-email and rate-limit cases are handled with accessible errors (`CR-84` shape). -- [ ] Profile reflects the new address after success. -- [ ] Documented session policy after email change. +- [x] New email is confirmed **only** after the user completes the link sent to that inbox; then `User.email` updates. +- [x] Duplicate-email and rate-limit cases are handled with accessible errors (`CR-84` shape). +- [x] Profile reflects the new address after success. +- [x] Documented session policy after email change. **Files (expected):** `prisma/schema.prisma`, new `app/api/user/...` or `app/api/auth/...` routes, [`lib/server/mail.ts`](../../lib/server/mail.ts), [`app/(app)/profile/`](../../app/(app)/profile/), [`messages/en/pages/profile.json`](../../messages/en/pages/profile.json), tests under `tests/unit/`. diff --git a/docs/guides/backend-roadmap.md b/docs/guides/backend-roadmap.md index 1d1e4ac..8a7a42f 100644 --- a/docs/guides/backend-roadmap.md +++ b/docs/guides/backend-roadmap.md @@ -23,6 +23,8 @@ Mirrors [CONTRIBUTING.md](../CONTRIBUTING.md) **API routes** table (including `/ | GET | `/api/auth/session` | Current user or null | | POST | `/api/auth/magic-link/request` | Send sign-in link email | | GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect | +| POST | `/api/user/email-change/request` | Authenticated: send verify link to new email ([CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)) | +| GET | `/api/user/email-change/verify` | Validate email-change token; update `User.email`; session policy; redirect | | POST | `/api/auth/logout` | Clear session | | GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) | | GET / POST | `/api/rules` | List or publish rules | @@ -78,13 +80,14 @@ Plain-English entities (names can evolve): | **User** | Identified by email after **magic link verification** (primary v1 path). An optional **display name** (or preferred name) could be added later for richer greetings; it does **not** block the profile page—no schema commitment in this roadmap pass alone. | | **Session** | **Custom v1:** HttpOnly cookie; opaque token; **hash** stored in DB ([`lib/server/session.ts`](lib/server/session.ts)). Not NextAuth/Lucia. | | **MagicLinkToken** | Short-lived **hashed** token for email sign-in links; optional `nextPath` for post-login redirect. | +| **EmailChangeToken** | One pending row per user (`userId` unique): hashed token + `newEmail` until verify ([CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)); separate from `MagicLinkToken`. | | **RuleDraft** | **One** JSON blob per user (create-flow state). Schema already has **`updatedAt`**; no draft **versioning** or **multiple named drafts** in v1. | | **PublishedRule** | Saved rule after publish (title, summary, document JSON). Profile UI badges such as **IN PROGRESS** may be **derived from `document` JSON**, a future `status` column, or UI-only—product decision when implementing Ticket 15. | | **RuleTemplate** | Curated templates (slug, category, ordering, `body` JSON). **v1 API** lists rows for cards / create entry; **not** yet a recommendation engine (see below). | **RuleTemplate — recommendation matrix (after v1 list):** Product may author templates in **spreadsheets** (e.g. one row per governance pattern, columns for **matching dimensions** such as group size, organization type, location, maturity, plus long-form fields for create-flow prefill). That implies: **normalized schema or versioned JSON** for dimensions × template fit (✓/✗, weights, or scores), an **import path** (export `.xlsx` / Sheets → validate → DB or build-time artifact), and **`GET /api/templates` (or a sibling route)** that accepts **user- or wizard-selected facets** and returns a **ranked or filtered** set. **Out of scope for first ship** of Tickets 7–8 (seed + display list); tracked as **Ticket 16** in [docs/backend-linear-tickets.md](backend-linear-tickets.md) and Linear **[CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-facet-data-seed-and-apis-no)** (**Done** — committed JSON + seed; no runtime `.xlsx`). Prefer **batch import** over live Google Sheets API in production unless ops explicitly wants sync. -**Session lifecycle (shipped, [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy)):** **Multi-device** policy — a new sign-in does **not** invalidate the user's other valid sessions. **Cleanup is lazy and cron-free:** every `createSessionForUser` prunes that user's expired rows (uses `@@index([userId])`); ~5% of sign-ins also run a global sweep so rows from users who never return remain bounded over months. Cleanup failures are logged but never fail the sign-in. **Rotation** on privilege-sensitive actions is deferred to v1.1. See the ADR comment block at the top of [`lib/server/session.ts`](../../lib/server/session.ts). Revisit a small auth library (e.g. Auth.js, Lucia) only if maintaining custom code becomes costly. +**Session lifecycle (shipped, [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy)):** **Multi-device** policy — a new sign-in does **not** invalidate the user's other valid sessions. **Exception — verified email change ([CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)):** after `User.email` updates via `/api/user/email-change/verify`, all other sessions for that user are revoked; the current browser keeps its session when it opened the verify link with a valid cookie, otherwise a **new** session is issued on the device that completed verify. **Cleanup is lazy and cron-free:** every `createSessionForUser` prunes that user's expired rows (uses `@@index([userId])`); ~5% of sign-ins also run a global sweep so rows from users who never return remain bounded over months. Cleanup failures are logged but never fail the sign-in. **Rotation** on other privilege-sensitive actions is deferred to v1.1. See the ADR comment block at the top of [`lib/server/session.ts`](../../lib/server/session.ts). Revisit a small auth library (e.g. Auth.js, Lucia) only if maintaining custom code becomes costly. **RuleDraft future (not v1):** versioning, multiple drafts per user, easier corruption recovery—only if product needs them. @@ -96,7 +99,7 @@ Align JSON shapes with `app/(app)/create/types.ts` as it matures. - **Decision:** **Custom** database-backed sessions + **email magic link**; cookies are **httpOnly**; session and magic-link tokens are hashed at rest. - **Rate limiting (magic-link request):** **In-memory** is acceptable for a **single Node process**. It does **not** coordinate across instances—**add a shared limiter (e.g. Redis)** before horizontal scaling or serious abuse exposure. -- **Lifecycle policy (shipped, [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy)):** multi-device (sign-in does not revoke other valid sessions); lazy expired-row cleanup on every sign-in (per-user prune + ~5% global sweep) — no cron required. Token rotation deferred to v1.1. Canonical comment block lives at the top of [`lib/server/session.ts`](../../lib/server/session.ts). +- **Lifecycle policy (shipped, [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy)):** multi-device (sign-in does not revoke other valid sessions); **email change ([CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session))** revokes other sessions as documented in [`lib/server/session.ts`](../../lib/server/session.ts) §4; lazy expired-row cleanup on every sign-in (per-user prune + ~5% global sweep) — no cron required. Token rotation deferred to v1.1. Canonical comment block lives at the top of [`lib/server/session.ts`](../../lib/server/session.ts). - Do **not** treat “switch to NextAuth/Lucia” as required for v1; document the custom lifecycle above instead. --- diff --git a/lib/create/api.ts b/lib/create/api.ts index 0943eae..aaf1965 100644 --- a/lib/create/api.ts +++ b/lib/create/api.ts @@ -70,6 +70,39 @@ export async function logout(): Promise { }); } +/** CR-103: send verify link to `newEmail` for the signed-in user. */ +export async function requestEmailChange( + newEmail: string, +): Promise<{ ok: true } | { ok: false; error: string; retryAfterMs?: number }> { + const res = await fetch("/api/user/email-change/request", { + method: "POST", + credentials: "include", + headers: jsonHeaders, + body: JSON.stringify({ newEmail }), + }); + const data: unknown = await res.json().catch(() => ({})); + if (!res.ok) { + let retryAfterMs: number | undefined; + if ( + res.status === 429 && + data && + typeof data === "object" && + "details" in data + ) { + const d = (data as { details?: { retryAfterMs?: unknown } }).details; + if (d && typeof d.retryAfterMs === "number") { + retryAfterMs = d.retryAfterMs; + } + } + return { + ok: false, + error: readApiErrorMessage(data), + retryAfterMs, + }; + } + return { ok: true }; +} + export async function fetchDraftFromServer(): Promise { const res = await fetch("/api/drafts/me", { credentials: "include" }); if (res.status === 401) return null; diff --git a/lib/server/mail.ts b/lib/server/mail.ts index bb2960a..2af3f32 100644 --- a/lib/server/mail.ts +++ b/lib/server/mail.ts @@ -25,3 +25,30 @@ export async function sendMagicLinkEmail( text: `Open this link to sign in (it expires in 15 minutes):\n\n${verifyUrl}\n\nIf you did not request this, you can ignore this email.`, }); } + +/** CR-103: confirm control of the new inbox before `User.email` is updated. */ +export async function sendEmailChangeEmail( + to: string, + verifyUrl: string, +): Promise { + const url = process.env.SMTP_URL; + + if (!url) { + if (process.env.NODE_ENV === "development") { + logger.info(`[dev] Email change verify for ${to}: ${verifyUrl}`); + return; + } + throw new Error("SMTP_URL is not configured"); + } + + const transporter = nodemailer.createTransport(url); + const from = process.env.SMTP_FROM ?? "noreply@localhost"; + + await transporter.sendMail({ + from, + to, + subject: "Confirm your new Community Rule email", + text: `You asked to change the email on your Community Rule account.\n\nOpen this link to confirm the new address (it expires in 15 minutes):\n\n${verifyUrl}\n\nIf you did not request this change, you can ignore this email. Your current login is unchanged until you confirm.`, + }); +} + diff --git a/lib/server/session.ts b/lib/server/session.ts index 9bc73cb..ecdc75a 100644 --- a/lib/server/session.ts +++ b/lib/server/session.ts @@ -26,6 +26,13 @@ import { hashSessionToken, newSessionToken } from "./hash"; * a global sweep so rows from users who never return are still bounded * over months. Cleanup is best-effort: a prune failure never fails the * sign-in itself. + * 4. **Email change (CR-103).** After a verified email update, revoke every + * `Session` for that `userId` **except** the current browser's session when + * the verify link is opened with a valid `cr_session` cookie for the same + * user. If there is no such session (e.g. user opened the link on another + * device), all sessions are removed and the verify handler issues a new + * session cookie so that device is signed in. Other devices must sign in + * again. */ export const SESSION_COOKIE_NAME = "cr_session"; @@ -56,6 +63,36 @@ export async function getSessionUser(): Promise { return session.user; } +/** + * When completing email change (CR-103), returns the current request's session + * `tokenHash` if the cookie maps to a non-expired session for `userId`; + * otherwise `null` (caller will drop all sessions and create a new one). + */ +export async function getValidatedSessionTokenHashForUser( + userId: string, +): Promise { + const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value; + if (!token) return null; + + let pepper: string; + try { + pepper = getSessionPepper(); + } catch { + return null; + } + + const tokenHash = hashSessionToken(token, pepper); + const session = await prisma.session.findUnique({ + where: { tokenHash }, + }); + + if (!session || session.expiresAt < new Date() || session.userId !== userId) { + return null; + } + + return tokenHash; +} + /** * Delete expired `Session` rows. Scoped to a single user when `userId` is * provided (uses the `@@index([userId])` lookup); otherwise sweeps the diff --git a/lib/server/validation/userEmailChangeSchemas.ts b/lib/server/validation/userEmailChangeSchemas.ts new file mode 100644 index 0000000..85a0815 --- /dev/null +++ b/lib/server/validation/userEmailChangeSchemas.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +/** POST `/api/user/email-change/request` body (CR-103). */ +export const emailChangeRequestBodySchema = z.object({ + newEmail: z + .string() + .trim() + .min(1, "Email is required") + .transform((s) => s.toLowerCase()) + .pipe(z.string().email({ message: "Valid email required" })), +}); + +export type EmailChangeRequestBody = z.infer< + typeof emailChangeRequestBodySchema +>; diff --git a/messages/en/pages/profile.json b/messages/en/pages/profile.json index 40bc3f9..185b589 100644 --- a/messages/en/pages/profile.json +++ b/messages/en/pages/profile.json @@ -37,7 +37,18 @@ "deleteDraftConfirmCta": "Delete draft", "accountHeading": "Account", "emailLabel": "Email", - "changeEmailComingSoon": "Change email — coming soon", + "emailChangeModalTitle": "Change your account email", + "emailChangeModalDescription": "We will send a confirmation link to the new address. Your email does not change until you open that link.", + "emailChangeNewEmailLabel": "New email", + "emailChangeNewEmailPlaceholder": "you@example.com", + "emailChangeCancel": "Cancel", + "emailChangeSubmit": "Send confirmation link", + "emailChangeRequestSent": "Check your inbox and confirm the new address using the link we sent.", + "emailChangeSuccess": "Your account email was updated.", + "emailChangeVerifyExpired": "That confirmation link expired or was already used. Start a new email change from your profile.", + "emailChangeVerifyInvalid": "That confirmation link is not valid.", + "emailChangeVerifyTaken": "That address was claimed by another account while you were confirming. Try a different email.", + "emailChangeRateLimited": "Too many requests. Try again in about {{seconds}} seconds.", "signOut": "Sign out", "deleteAccount": "Delete account", "deleteAccountIntro": "Permanently delete your account and sign-in data. Your published rules will remain visible without an owner name.", diff --git a/prisma/migrations/20260426000815_add_email_change_token/migration.sql b/prisma/migrations/20260426000815_add_email_change_token/migration.sql new file mode 100644 index 0000000..c7a48aa --- /dev/null +++ b/prisma/migrations/20260426000815_add_email_change_token/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "EmailChangeToken" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "newEmail" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "EmailChangeToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "EmailChangeToken_userId_key" ON "EmailChangeToken"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "EmailChangeToken_tokenHash_key" ON "EmailChangeToken"("tokenHash"); + +-- CreateIndex +CREATE INDEX "EmailChangeToken_newEmail_idx" ON "EmailChangeToken"("newEmail"); + +-- AddForeignKey +ALTER TABLE "EmailChangeToken" ADD CONSTRAINT "EmailChangeToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 602a80b..78f790f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,9 +13,25 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - sessions Session[] - draft RuleDraft? - rules PublishedRule[] + sessions Session[] + draft RuleDraft? + rules PublishedRule[] + /// At most one pending verified email change (CR-103). + emailChangeToken EmailChangeToken? +} + +/// Pending email change: user must open verify link sent to `newEmail` (CR-103). +/// Separate from `MagicLinkToken` so sign-in and email-change flows cannot be confused. +model EmailChangeToken { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + newEmail String + tokenHash String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([newEmail]) } model Session { diff --git a/tests/unit/userEmailChangeRequestRoute.test.ts b/tests/unit/userEmailChangeRequestRoute.test.ts new file mode 100644 index 0000000..62dd7b7 --- /dev/null +++ b/tests/unit/userEmailChangeRequestRoute.test.ts @@ -0,0 +1,176 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const isDatabaseConfiguredMock = vi.fn(); +const getSessionUserMock = vi.fn(); +const userFindUniqueMock = vi.fn(); +const emailChangeDeleteManyMock = vi.fn(); +const emailChangeCreateMock = vi.fn(); +const rateLimitKeyMock = vi.fn(); +const sendEmailChangeEmailMock = vi.fn(); + +vi.mock("../../lib/server/env", () => ({ + isDatabaseConfigured: () => isDatabaseConfiguredMock(), + getSessionPepper: () => "test-pepper", +})); + +vi.mock("../../lib/server/rateLimit", () => ({ + rateLimitKey: (...args: unknown[]) => rateLimitKeyMock(...args), +})); + +vi.mock("../../lib/server/mail", () => ({ + sendEmailChangeEmail: (...args: unknown[]) => + sendEmailChangeEmailMock(...args), +})); + +vi.mock("../../lib/server/db", () => ({ + prisma: { + user: { + findUnique: (...args: unknown[]) => userFindUniqueMock(...args), + }, + emailChangeToken: { + deleteMany: (...args: unknown[]) => emailChangeDeleteManyMock(...args), + create: (...args: unknown[]) => emailChangeCreateMock(...args), + }, + }, +})); + +vi.mock("../../lib/server/session", () => ({ + getSessionUser: () => getSessionUserMock(), +})); + +vi.mock("../../lib/server/hash", () => ({ + hashSessionToken: () => "hashed-token", + newSessionToken: () => "raw-token-1234567890", +})); + +import { POST } from "../../app/api/user/email-change/request/route"; + +function postJson(body: unknown) { + return new NextRequest("https://x.test/api/user/email-change/request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + isDatabaseConfiguredMock.mockReset(); + getSessionUserMock.mockReset(); + userFindUniqueMock.mockReset(); + emailChangeDeleteManyMock.mockReset(); + emailChangeCreateMock.mockReset(); + rateLimitKeyMock.mockReset(); + sendEmailChangeEmailMock.mockReset(); + rateLimitKeyMock.mockReturnValue({ ok: true as const }); +}); + +describe("POST /api/user/email-change/request", () => { + it("returns 503 when the database is not configured", async () => { + isDatabaseConfiguredMock.mockReturnValue(false); + const res = await POST(postJson({ newEmail: "n@x.com" }), undefined); + expect(res.status).toBe(503); + expect(getSessionUserMock).not.toHaveBeenCalled(); + }); + + it("returns 401 when not signed in", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + getSessionUserMock.mockResolvedValue(null); + const res = await POST(postJson({ newEmail: "n@x.com" }), undefined); + expect(res.status).toBe(401); + expect(userFindUniqueMock).not.toHaveBeenCalled(); + }); + + it("returns 400 when new email equals current email", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + getSessionUserMock.mockResolvedValue({ + id: "u1", + email: "same@x.com", + }); + const res = await POST(postJson({ newEmail: "same@x.com" }), undefined); + expect(res.status).toBe(400); + const body = (await res.json()) as { error?: { code?: string } }; + expect(body.error?.code).toBe("validation_error"); + expect(emailChangeCreateMock).not.toHaveBeenCalled(); + }); + + it("returns 400 when the email is taken by another user", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + getSessionUserMock.mockResolvedValue({ + id: "u1", + email: "old@x.com", + }); + userFindUniqueMock.mockResolvedValueOnce({ + id: "u2", + email: "taken@x.com", + }); + const res = await POST(postJson({ newEmail: "taken@x.com" }), undefined); + expect(res.status).toBe(400); + expect(emailChangeCreateMock).not.toHaveBeenCalled(); + }); + + it("returns 429 when rate limited", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + getSessionUserMock.mockResolvedValue({ + id: "u1", + email: "old@x.com", + }); + userFindUniqueMock.mockResolvedValueOnce(null); + rateLimitKeyMock.mockReturnValueOnce({ + ok: false as const, + retryAfterMs: 5000, + }); + const res = await POST(postJson({ newEmail: "new@x.com" }), undefined); + expect(res.status).toBe(429); + expect(emailChangeCreateMock).not.toHaveBeenCalled(); + }); + + it("creates a token and sends mail on success", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + getSessionUserMock.mockResolvedValue({ + id: "u1", + email: "old@x.com", + }); + userFindUniqueMock.mockResolvedValueOnce(null); + emailChangeDeleteManyMock.mockResolvedValueOnce({ count: 0 }); + emailChangeCreateMock.mockResolvedValueOnce({ id: "t1" }); + sendEmailChangeEmailMock.mockResolvedValueOnce(undefined); + + const res = await POST(postJson({ newEmail: "new@x.com" }), undefined); + expect(res.status).toBe(200); + expect(emailChangeDeleteManyMock).toHaveBeenCalledWith({ + where: { userId: "u1" }, + }); + expect(emailChangeCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: "u1", + newEmail: "new@x.com", + tokenHash: "hashed-token", + }), + }), + ); + expect(sendEmailChangeEmailMock).toHaveBeenCalledWith( + "new@x.com", + expect.stringContaining("/api/user/email-change/verify?token="), + ); + }); + + it("rolls back the token when mail send fails", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + getSessionUserMock.mockResolvedValue({ + id: "u1", + email: "old@x.com", + }); + userFindUniqueMock.mockResolvedValueOnce(null); + emailChangeDeleteManyMock.mockResolvedValue({ count: 0 }); + emailChangeCreateMock.mockResolvedValue({ id: "t1" }); + sendEmailChangeEmailMock.mockRejectedValueOnce(new Error("smtp down")); + + const res = await POST(postJson({ newEmail: "new@x.com" }), undefined); + expect(res.status).toBe(502); + expect(emailChangeDeleteManyMock).toHaveBeenLastCalledWith({ + where: { userId: "u1" }, + }); + }); +}); diff --git a/tests/unit/userEmailChangeVerifyRoute.test.ts b/tests/unit/userEmailChangeVerifyRoute.test.ts new file mode 100644 index 0000000..46d95e7 --- /dev/null +++ b/tests/unit/userEmailChangeVerifyRoute.test.ts @@ -0,0 +1,245 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const isDatabaseConfiguredMock = vi.fn(); +const getSessionPepperMock = vi.fn(); +const hashSessionTokenMock = vi.fn(); +const getValidatedSessionTokenHashForUserMock = vi.fn(); +const createSessionForUserMock = vi.fn(); +const setSessionCookieMock = vi.fn(); +const emailChangeFindUniqueMock = vi.fn(); +const transactionMock = vi.fn(); + +vi.mock("../../lib/server/env", () => ({ + isDatabaseConfigured: () => isDatabaseConfiguredMock(), + getSessionPepper: () => getSessionPepperMock(), +})); + +vi.mock("../../lib/server/hash", () => ({ + hashSessionToken: (...args: unknown[]) => hashSessionTokenMock(...args), +})); + +vi.mock("../../lib/server/session", () => ({ + getValidatedSessionTokenHashForUser: (...args: unknown[]) => + getValidatedSessionTokenHashForUserMock(...args), + createSessionForUser: (...args: unknown[]) => + createSessionForUserMock(...args), + setSessionCookie: (...args: unknown[]) => setSessionCookieMock(...args), +})); + +vi.mock("../../lib/server/db", () => ({ + prisma: { + emailChangeToken: { + findUnique: (...args: unknown[]) => emailChangeFindUniqueMock(...args), + }, + $transaction: (...args: unknown[]) => transactionMock(...args), + }, +})); + +import { GET } from "../../app/api/user/email-change/verify/route"; + +beforeEach(() => { + isDatabaseConfiguredMock.mockReset(); + getSessionPepperMock.mockReset(); + hashSessionTokenMock.mockReset(); + getValidatedSessionTokenHashForUserMock.mockReset(); + createSessionForUserMock.mockReset(); + setSessionCookieMock.mockReset(); + emailChangeFindUniqueMock.mockReset(); + transactionMock.mockReset(); + + getSessionPepperMock.mockReturnValue("pepper"); + hashSessionTokenMock.mockReturnValue("token-hash"); +}); + +function getWithToken(token: string) { + return new NextRequest( + `https://x.test/api/user/email-change/verify?token=${encodeURIComponent(token)}`, + ); +} + +describe("GET /api/user/email-change/verify", () => { + it("returns 503 when the database is not configured", async () => { + isDatabaseConfiguredMock.mockReturnValue(false); + const res = await GET( + new NextRequest("https://x.test/api/user/email-change/verify?token=abc"), + ); + expect(res.status).toBe(503); + }); + + it("redirects with email_change_invalid when token is missing", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + const res = await GET( + new NextRequest("https://x.test/api/user/email-change/verify"), + ); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("error=email_change_invalid"); + }); + + it("redirects with email_change_invalid when token is too short", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + const res = await GET(getWithToken("short")); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("error=email_change_invalid"); + }); + + it("redirects with email_change_expired when row is missing", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + emailChangeFindUniqueMock.mockResolvedValueOnce(null); + const res = await GET(getWithToken("long-enough-token")); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("error=email_change_expired"); + }); + + it("redirects with email_change_expired when token is past expiresAt", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + emailChangeFindUniqueMock.mockResolvedValueOnce({ + id: "tok1", + userId: "u1", + newEmail: "new@x.com", + expiresAt: new Date("2020-01-01T00:00:00Z"), + tokenHash: "token-hash", + }); + const res = await GET(getWithToken("long-enough-token")); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("error=email_change_expired"); + }); + + it("redirects with email_change_taken when another user claims the email in the transaction", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + const future = new Date(Date.now() + 60_000); + emailChangeFindUniqueMock.mockResolvedValueOnce({ + id: "tok1", + userId: "u1", + newEmail: "new@x.com", + expiresAt: future, + tokenHash: "token-hash", + }); + getValidatedSessionTokenHashForUserMock.mockResolvedValueOnce("keep-hash"); + + transactionMock.mockImplementationOnce( + async (fn: (tx: Record) => Promise) => { + const tx = { + emailChangeToken: { + findUnique: vi.fn().mockResolvedValue({ + id: "tok1", + userId: "u1", + newEmail: "new@x.com", + expiresAt: future, + }), + delete: vi.fn().mockResolvedValue({}), + }, + user: { + findFirst: vi.fn().mockResolvedValue({ id: "u2" }), + }, + }; + await fn(tx as Record); + }, + ); + + const res = await GET(getWithToken("long-enough-token")); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("error=email_change_taken"); + }); + + it("redirects with email_change_ok and keeps session when validated session matches", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + const future = new Date(Date.now() + 60_000); + emailChangeFindUniqueMock.mockResolvedValueOnce({ + id: "tok1", + userId: "u1", + newEmail: "new@x.com", + expiresAt: future, + tokenHash: "token-hash", + }); + getValidatedSessionTokenHashForUserMock.mockResolvedValueOnce("keep-hash"); + + const sessionDeleteMany = vi.fn().mockResolvedValue({ count: 1 }); + const userUpdate = vi.fn().mockResolvedValue({}); + const tokenDelete = vi.fn().mockResolvedValue({}); + + transactionMock.mockImplementationOnce( + async (fn: (tx: Record) => Promise) => { + const tx = { + emailChangeToken: { + findUnique: vi.fn().mockResolvedValue({ + id: "tok1", + userId: "u1", + newEmail: "new@x.com", + expiresAt: future, + }), + delete: tokenDelete, + }, + user: { + findFirst: vi.fn().mockResolvedValue(null), + update: userUpdate, + }, + session: { deleteMany: sessionDeleteMany }, + }; + await fn(tx as Record); + }, + ); + + const res = await GET(getWithToken("long-enough-token")); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("email_change=ok"); + expect(userUpdate).toHaveBeenCalledWith({ + where: { id: "u1" }, + data: { email: "new@x.com" }, + }); + expect(sessionDeleteMany).toHaveBeenCalledWith({ + where: { userId: "u1", tokenHash: { not: "keep-hash" } }, + }); + expect(createSessionForUserMock).not.toHaveBeenCalled(); + }); + + it("issues a new session when no validated session is kept", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + const future = new Date(Date.now() + 60_000); + emailChangeFindUniqueMock.mockResolvedValueOnce({ + id: "tok1", + userId: "u1", + newEmail: "new@x.com", + expiresAt: future, + tokenHash: "token-hash", + }); + getValidatedSessionTokenHashForUserMock.mockResolvedValueOnce(null); + + const sessionDeleteMany = vi.fn().mockResolvedValue({ count: 2 }); + transactionMock.mockImplementationOnce( + async (fn: (tx: Record) => Promise) => { + const tx = { + emailChangeToken: { + findUnique: vi.fn().mockResolvedValue({ + id: "tok1", + userId: "u1", + newEmail: "new@x.com", + expiresAt: future, + }), + delete: vi.fn().mockResolvedValue({}), + }, + user: { + findFirst: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue({}), + }, + session: { deleteMany: sessionDeleteMany }, + }; + await fn(tx as Record); + }, + ); + + createSessionForUserMock.mockResolvedValueOnce({ + token: "sess", + expiresAt: new Date("2026-06-01T00:00:00Z"), + }); + + const res = await GET(getWithToken("long-enough-token")); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("email_change=ok"); + expect(sessionDeleteMany).toHaveBeenCalledWith({ + where: { userId: "u1" }, + }); + expect(createSessionForUserMock).toHaveBeenCalledWith("u1"); + expect(setSessionCookieMock).toHaveBeenCalled(); + }); +});