Implement email change

This commit is contained in:
adilallo
2026-04-26 07:47:25 -06:00
parent 68517796a9
commit 0ce05372bf
15 changed files with 1072 additions and 13 deletions
+87 -1
View File
@@ -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<string | null>(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}
@@ -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({
)}
</header>
{profileSuccessMessage ? (
<p
className="rounded-lg border border-[var(--color-border-default-secondary)] bg-[var(--color-surface-default-secondary)] px-4 py-3 font-inter text-sm text-[var(--color-content-default-primary)]"
role="status"
>
{profileSuccessMessage}
</p>
) : null}
{actionError ? (
<p
className="rounded-lg border border-[var(--color-border-default-secondary)] bg-[var(--color-surface-default-tertiary)] px-4 py-3 font-inter text-sm text-[var(--color-content-default-primary)]"
@@ -484,6 +512,60 @@ export function ProfilePageView({
</>
}
/>
<Dialog
isOpen={emailChangeOpen}
onClose={() => {
if (!emailChangeBusy) onCloseEmailChange();
}}
backdropVariant="blurredYellow"
title={t("emailChangeModalTitle")}
description={t("emailChangeModalDescription")}
footer={
<>
<Button
type="button"
size="medium"
buttonType="outline"
palette="default"
onClick={onCloseEmailChange}
disabled={emailChangeBusy}
>
{t("emailChangeCancel")}
</Button>
<Button
type="button"
size="medium"
buttonType="filled"
palette="default"
onClick={onSubmitEmailChange}
disabled={emailChangeBusy}
>
{t("emailChangeSubmit")}
</Button>
</>
}
>
{emailChangeModalError ? (
<p
className="font-inter text-sm text-[var(--color-content-default-primary)]"
role="alert"
>
{emailChangeModalError}
</p>
) : null}
<TextInput
type="email"
inputSize="medium"
label={t("emailChangeNewEmailLabel")}
placeholder={t("emailChangeNewEmailPlaceholder")}
value={emailChangeValue}
onChange={(e) => onEmailChangeValueChange(e.target.value)}
disabled={emailChangeBusy}
error={Boolean(emailChangeModalError)}
autoComplete="email"
/>
</Dialog>
</>
);
}
+133
View File
@@ -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 });
});
+172
View File
@@ -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;
}
+4 -4
View File
@@ -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/`.
+5 -2
View File
@@ -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 78 (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.
---
+33
View File
@@ -70,6 +70,39 @@ export async function logout(): Promise<void> {
});
}
/** 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<CreateFlowState | null> {
const res = await fetch("/api/drafts/me", { credentials: "include" });
if (res.status === 401) return null;
+27
View File
@@ -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<void> {
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.`,
});
}
+37
View File
@@ -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<User | null> {
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<string | null> {
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
@@ -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
>;
+12 -1
View File
@@ -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.",
@@ -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;
+19 -3
View File
@@ -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 {
@@ -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" },
});
});
});
@@ -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<string, unknown>) => Promise<void>) => {
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<string, unknown>);
},
);
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<string, unknown>) => Promise<void>) => {
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<string, unknown>);
},
);
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<string, unknown>) => Promise<void>) => {
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<string, unknown>);
},
);
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();
});
});