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;
}