diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index 6d93276..e510bdc 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -249,6 +249,30 @@ export async function createMagicToken(email: string): Promise { return token; } +export async function initiateEmailChange(userId: string, newEmail: string): Promise { + const db = getDb(); + + const [existing] = await db.select().from(users).where(eq(users.email, newEmail)); + if (existing) throw new Error("Email already in use"); + + const [user] = await db.select().from(users).where(eq(users.id, userId)); + if (!user) throw new Error("User not found"); + if (user.email === newEmail) throw new Error("This is already your email"); + + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + + await db.insert(magicTokens).values({ + id: randomUUID(), + email: newEmail, + purpose: "email-change", + token, + expiresAt, + }); + + return token; +} + export async function verifyMagicToken(token: string): Promise { const db = getDb(); @@ -258,6 +282,7 @@ export async function verifyMagicToken(token: string): Promise { .where( and( eq(magicTokens.token, token), + eq(magicTokens.purpose, "login"), gt(magicTokens.expiresAt, new Date()), isNull(magicTokens.usedAt), ), @@ -265,19 +290,57 @@ export async function verifyMagicToken(token: string): Promise { if (!record) throw new Error("Invalid or expired magic link"); - // Mark as used await db .update(magicTokens) .set({ usedAt: new Date() }) .where(eq(magicTokens.id, record.id)); - // Find user const [user] = await db.select().from(users).where(eq(users.email, record.email)); if (!user) throw new Error("User not found"); return user.id; } +export async function verifyEmailChange(token: string, userId: string): Promise { + const db = getDb(); + + const [record] = await db + .select() + .from(magicTokens) + .where( + and( + eq(magicTokens.token, token), + eq(magicTokens.purpose, "email-change"), + gt(magicTokens.expiresAt, new Date()), + isNull(magicTokens.usedAt), + ), + ); + + if (!record) throw new Error("Invalid or expired verification link"); + + const newEmail = record.email; + + // Re-check email availability at verification time — someone may have + // registered with this email between initiation and verification + const [existing] = await db.select().from(users).where(eq(users.email, newEmail)); + if (existing) { + await db.update(magicTokens).set({ usedAt: new Date() }).where(eq(magicTokens.id, record.id)); + throw new Error("This email is now in use by another account"); + } + + await db + .update(magicTokens) + .set({ usedAt: new Date() }) + .where(eq(magicTokens.id, record.id)); + + await db + .update(users) + .set({ email: newEmail }) + .where(eq(users.id, userId)); + + return newEmail; +} + // --- Sessions --- import { createCookieSessionStorage } from "react-router"; diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index cdd85c1..9f35ab8 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -76,6 +76,9 @@ function NavBar({ user }: { user: { id: string; username: string } | null }) { > {user.username} + + {t("nav.settings")} +