Implement email change
This commit is contained in:
@@ -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
|
||||
>;
|
||||
Reference in New Issue
Block a user