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