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;
|
||||
}
|
||||
Reference in New Issue
Block a user