Implement email change
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user