diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index e510bdc..d44e4cd 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -229,7 +229,13 @@ export async function finishAuthentication( // --- Magic Links --- -export async function createMagicToken(email: string): Promise { +function generateLoginCode(): string { + // 6-digit numeric code, zero-padded + const num = randomBytes(3).readUIntBE(0, 3) % 1_000_000; + return String(num).padStart(6, "0"); +} + +export async function createMagicToken(email: string): Promise<{ token: string; code: string }> { const db = getDb(); // Check user exists @@ -237,16 +243,47 @@ export async function createMagicToken(email: string): Promise { if (!user) throw new Error("No account found for this email"); const token = randomBytes(32).toString("base64url"); + const code = generateLoginCode(); const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes await db.insert(magicTokens).values({ id: randomUUID(), email, token, + code, expiresAt, }); - return token; + return { token, code }; +} + +export async function verifyLoginCode(email: string, code: string): Promise { + const db = getDb(); + + const [record] = await db + .select() + .from(magicTokens) + .where( + and( + eq(magicTokens.email, email), + eq(magicTokens.code, code), + eq(magicTokens.purpose, "login"), + gt(magicTokens.expiresAt, new Date()), + isNull(magicTokens.usedAt), + ), + ); + + if (!record) throw new Error("Invalid or expired code"); + + await db + .update(magicTokens) + .set({ usedAt: new Date() }) + .where(eq(magicTokens.id, record.id)); + + 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 initiateEmailChange(userId: string, newEmail: string): Promise { diff --git a/apps/journal/app/lib/email.server.ts b/apps/journal/app/lib/email.server.ts index 50b2209..3b5a856 100644 --- a/apps/journal/app/lib/email.server.ts +++ b/apps/journal/app/lib/email.server.ts @@ -30,23 +30,33 @@ export async function sendEmail( // --- Templates --- -export function magicLinkTemplate(link: string): { html: string; text: string } { +export function magicLinkTemplate(link: string, code?: string): { html: string; text: string } { + const codeSection = code + ? `

Or enter this code on the login page:

+
+ ${code} +
` + : ""; + const html = `

Sign in to trails.cool

Click the button below to sign in to your account. This link expires in 15 minutes.

Sign In + ${codeSection}

If the button doesn't work, copy and paste this link:
${link}


If you didn't request this link, you can safely ignore this email.

`.trim(); + const codeText = code ? `\nOr enter this code: ${code}\n` : ""; + const text = [ "Sign in to trails.cool", "", `Click here to sign in: ${link}`, - "", + codeText, "This link expires in 15 minutes.", "", "If you didn't request this link, you can safely ignore this email.", @@ -89,8 +99,8 @@ export function welcomeTemplate(username: string): { html: string; text: string // --- Convenience wrappers --- -export async function sendMagicLink(email: string, link: string): Promise { - const { html, text } = magicLinkTemplate(link); +export async function sendMagicLink(email: string, link: string, code?: string): Promise { + const { html, text } = magicLinkTemplate(link, code); await sendEmail(email, "Sign in to trails.cool", html, text); } diff --git a/apps/journal/app/routes/api.auth.login.ts b/apps/journal/app/routes/api.auth.login.ts index e4f766e..7fa122e 100644 --- a/apps/journal/app/routes/api.auth.login.ts +++ b/apps/journal/app/routes/api.auth.login.ts @@ -1,11 +1,11 @@ import { data } from "react-router"; import type { Route } from "./+types/api.auth.login"; -import { startAuthentication, finishAuthentication, createMagicToken, createSession } from "~/lib/auth.server"; +import { startAuthentication, finishAuthentication, createMagicToken, verifyLoginCode, createSession } from "~/lib/auth.server"; import { sendMagicLink } from "~/lib/email.server"; export async function action({ request }: Route.ActionArgs) { const body = await request.json(); - const { step, response, challenge, email } = body; + const { step, response, challenge, email, code } = body; try { if (step === "start-passkey") { @@ -20,19 +20,28 @@ export async function action({ request }: Route.ActionArgs) { } if (step === "magic-link") { - const token = await createMagicToken(email); + const { token, code: loginCode } = await createMagicToken(email); const origin = process.env.ORIGIN ?? "http://localhost:3000"; const link = `${origin}/auth/verify?token=${token}`; - // In dev, return the link directly so the client can auto-redirect + // In dev, return the link and code directly if (process.env.NODE_ENV !== "production") { - console.log(`[Magic Link] ${email}: ${link}`); - return data({ step: "magic-link-sent", devLink: link }); + console.log(`[Magic Link] ${email}: ${link} (code: ${loginCode})`); + return data({ step: "magic-link-sent", devLink: link, code: loginCode }); } - await sendMagicLink(email, link); + await sendMagicLink(email, link, loginCode); return data({ step: "magic-link-sent" }); } + if (step === "verify-code") { + if (!email || !code) { + return data({ error: "Email and code are required" }, { status: 400 }); + } + const userId = await verifyLoginCode(email, code); + const cookie = await createSession(userId, request); + return data({ step: "done" }, { headers: { "Set-Cookie": cookie } }); + } + return data({ error: "Invalid step" }, { status: 400 }); } catch (e) { return data({ error: (e as Error).message }, { status: 400 }); diff --git a/apps/journal/app/routes/auth.login.tsx b/apps/journal/app/routes/auth.login.tsx index 3a6bd18..1201c4b 100644 --- a/apps/journal/app/routes/auth.login.tsx +++ b/apps/journal/app/routes/auth.login.tsx @@ -17,6 +17,7 @@ export default function LoginPage() { }); }, []); const [email, setEmail] = useState(""); + const [loginCode, setLoginCode] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [magicLinkSent, setMagicLinkSent] = useState(false); @@ -70,6 +71,31 @@ export default function LoginPage() { } }; + const handleCodeVerify = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + const resp = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step: "verify-code", email, code: loginCode }), + }); + const result = await resp.json(); + + if (result.error) { + setError(result.error); + } else if (result.step === "done") { + window.location.href = returnTo ?? "/"; + } + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + const handleMagicLink = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -168,13 +194,36 @@ export default function LoginPage() { )} {magicLinkSent && ( -
-

- {t("auth.checkEmail")} {email}. -

-

- {t("auth.linkExpires")} -

+
+
+

+ {t("auth.checkEmail")} {email}. +

+

+ {t("auth.linkExpires")} +

+
+ +
+

{t("auth.codeHelp")}

+ setLoginCode(e.target.value.replace(/\D/g, "").slice(0, 6))} + className="block w-full rounded-md border border-gray-300 px-3 py-3 text-center text-2xl font-mono tracking-[0.3em] shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + +
)} diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 19de61c..90d79a6 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -49,6 +49,7 @@ export const magicTokens = journalSchema.table("magic_tokens", { id: text("id").primaryKey(), email: text("email").notNull(), token: text("token").notNull().unique(), + code: text("code"), purpose: text("purpose").notNull().default("login"), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), usedAt: timestamp("used_at", { withTimezone: true }), diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 3978862..dd6fabe 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -265,6 +265,8 @@ export default { backToPasskey: "Zurück zur Passkey-Anmeldung", checkEmail: "Prüfe deine E-Mails! Wir haben einen Anmeldelink gesendet an", linkExpires: "Der Link ist 15 Minuten gültig.", + codeHelp: "Oder gib den 6-stelligen Code aus der E-Mail ein:", + verifyCode: "Code bestätigen", noAccount: "Noch kein Konto?", registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.", registerWithPasskey: "Mit Passkey registrieren", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index a067869..b2dbf9c 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -265,6 +265,8 @@ export default { backToPasskey: "Back to passkey login", checkEmail: "Check your email! We sent a login link to", linkExpires: "The link expires in 15 minutes.", + codeHelp: "Or enter the 6-digit code from the email:", + verifyCode: "Verify Code", noAccount: "Don't have an account?", registerDescription: "Register with a passkey — no password needed.", registerWithPasskey: "Register with Passkey",