Add 6-digit login code for mobile authentication

Magic links open in the device browser, not the OAuth in-app browser,
so the session doesn't carry over. A login code lets mobile users
type a 6-digit code from their email into the login page instead.

- Generate 6-digit numeric code alongside magic link token
- Store code in magic_tokens table
- Add verify-code step to login API endpoint
- Show code input UI after magic link is sent
- Include code in email template
- i18n keys for code UI (en + de)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-15 20:56:42 +02:00
parent 2a94af5655
commit 3bcb1fce7c
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
7 changed files with 130 additions and 20 deletions

View file

@ -229,7 +229,13 @@ export async function finishAuthentication(
// --- Magic Links --- // --- Magic Links ---
export async function createMagicToken(email: string): Promise<string> { 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(); const db = getDb();
// Check user exists // Check user exists
@ -237,16 +243,47 @@ export async function createMagicToken(email: string): Promise<string> {
if (!user) throw new Error("No account found for this email"); if (!user) throw new Error("No account found for this email");
const token = randomBytes(32).toString("base64url"); const token = randomBytes(32).toString("base64url");
const code = generateLoginCode();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
await db.insert(magicTokens).values({ await db.insert(magicTokens).values({
id: randomUUID(), id: randomUUID(),
email, email,
token, token,
code,
expiresAt, expiresAt,
}); });
return token; return { token, code };
}
export async function verifyLoginCode(email: string, code: string): Promise<string> {
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<string> { export async function initiateEmailChange(userId: string, newEmail: string): Promise<string> {

View file

@ -30,23 +30,33 @@ export async function sendEmail(
// --- Templates --- // --- Templates ---
export function magicLinkTemplate(link: string): { html: string; text: string } { export function magicLinkTemplate(link: string, code?: string): { html: string; text: string } {
const codeSection = code
? `<p style="color: #555; line-height: 1.6;">Or enter this code on the login page:</p>
<div style="margin: 16px 0; padding: 16px; background: #f3f4f6; border-radius: 8px; text-align: center;">
<span style="font-size: 32px; font-weight: 700; letter-spacing: 8px; color: #111;">${code}</span>
</div>`
: "";
const html = ` const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;"> <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
<h1 style="font-size: 24px; font-weight: 600; color: #111;">Sign in to trails.cool</h1> <h1 style="font-size: 24px; font-weight: 600; color: #111;">Sign in to trails.cool</h1>
<p style="color: #555; line-height: 1.6;">Click the button below to sign in to your account. This link expires in 15 minutes.</p> <p style="color: #555; line-height: 1.6;">Click the button below to sign in to your account. This link expires in 15 minutes.</p>
<a href="${link}" style="display: inline-block; margin: 24px 0; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 8px; font-weight: 500;">Sign In</a> <a href="${link}" style="display: inline-block; margin: 24px 0; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 8px; font-weight: 500;">Sign In</a>
${codeSection}
<p style="color: #888; font-size: 14px;">If the button doesn't work, copy and paste this link:<br/><a href="${link}" style="color: #2563eb;">${link}</a></p> <p style="color: #888; font-size: 14px;">If the button doesn't work, copy and paste this link:<br/><a href="${link}" style="color: #2563eb;">${link}</a></p>
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" /> <hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
<p style="color: #aaa; font-size: 12px;">If you didn't request this link, you can safely ignore this email.</p> <p style="color: #aaa; font-size: 12px;">If you didn't request this link, you can safely ignore this email.</p>
</div> </div>
`.trim(); `.trim();
const codeText = code ? `\nOr enter this code: ${code}\n` : "";
const text = [ const text = [
"Sign in to trails.cool", "Sign in to trails.cool",
"", "",
`Click here to sign in: ${link}`, `Click here to sign in: ${link}`,
"", codeText,
"This link expires in 15 minutes.", "This link expires in 15 minutes.",
"", "",
"If you didn't request this link, you can safely ignore this email.", "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 --- // --- Convenience wrappers ---
export async function sendMagicLink(email: string, link: string): Promise<void> { export async function sendMagicLink(email: string, link: string, code?: string): Promise<void> {
const { html, text } = magicLinkTemplate(link); const { html, text } = magicLinkTemplate(link, code);
await sendEmail(email, "Sign in to trails.cool", html, text); await sendEmail(email, "Sign in to trails.cool", html, text);
} }

View file

@ -1,11 +1,11 @@
import { data } from "react-router"; import { data } from "react-router";
import type { Route } from "./+types/api.auth.login"; 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"; import { sendMagicLink } from "~/lib/email.server";
export async function action({ request }: Route.ActionArgs) { export async function action({ request }: Route.ActionArgs) {
const body = await request.json(); const body = await request.json();
const { step, response, challenge, email } = body; const { step, response, challenge, email, code } = body;
try { try {
if (step === "start-passkey") { if (step === "start-passkey") {
@ -20,19 +20,28 @@ export async function action({ request }: Route.ActionArgs) {
} }
if (step === "magic-link") { 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 origin = process.env.ORIGIN ?? "http://localhost:3000";
const link = `${origin}/auth/verify?token=${token}`; 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") { if (process.env.NODE_ENV !== "production") {
console.log(`[Magic Link] ${email}: ${link}`); console.log(`[Magic Link] ${email}: ${link} (code: ${loginCode})`);
return data({ step: "magic-link-sent", devLink: link }); 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" }); 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 }); return data({ error: "Invalid step" }, { status: 400 });
} catch (e) { } catch (e) {
return data({ error: (e as Error).message }, { status: 400 }); return data({ error: (e as Error).message }, { status: 400 });

View file

@ -17,6 +17,7 @@ export default function LoginPage() {
}); });
}, []); }, []);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [loginCode, setLoginCode] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [magicLinkSent, setMagicLinkSent] = 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) => { const handleMagicLink = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
@ -168,13 +194,36 @@ export default function LoginPage() {
)} )}
{magicLinkSent && ( {magicLinkSent && (
<div className="mt-8 rounded-md bg-green-50 p-4"> <div className="mt-8 space-y-4">
<p className="text-sm text-green-800"> <div className="rounded-md bg-green-50 p-4">
{t("auth.checkEmail")} <strong>{email}</strong>. <p className="text-sm text-green-800">
</p> {t("auth.checkEmail")} <strong>{email}</strong>.
<p className="mt-2 text-xs text-green-600"> </p>
{t("auth.linkExpires")} <p className="mt-2 text-xs text-green-600">
</p> {t("auth.linkExpires")}
</p>
</div>
<form onSubmit={handleCodeVerify} className="space-y-3">
<p className="text-sm text-gray-600">{t("auth.codeHelp")}</p>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
placeholder="000000"
value={loginCode}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={loading || loginCode.length !== 6}
className="w-full rounded-md bg-gray-800 px-4 py-2 text-white hover:bg-gray-900 disabled:opacity-50"
>
{loading ? t("auth.authenticating") : t("auth.verifyCode")}
</button>
</form>
</div> </div>
)} )}

View file

@ -49,6 +49,7 @@ export const magicTokens = journalSchema.table("magic_tokens", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
email: text("email").notNull(), email: text("email").notNull(),
token: text("token").notNull().unique(), token: text("token").notNull().unique(),
code: text("code"),
purpose: text("purpose").notNull().default("login"), purpose: text("purpose").notNull().default("login"),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
usedAt: timestamp("used_at", { withTimezone: true }), usedAt: timestamp("used_at", { withTimezone: true }),

View file

@ -265,6 +265,8 @@ export default {
backToPasskey: "Zurück zur Passkey-Anmeldung", backToPasskey: "Zurück zur Passkey-Anmeldung",
checkEmail: "Prüfe deine E-Mails! Wir haben einen Anmeldelink gesendet an", checkEmail: "Prüfe deine E-Mails! Wir haben einen Anmeldelink gesendet an",
linkExpires: "Der Link ist 15 Minuten gültig.", 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?", noAccount: "Noch kein Konto?",
registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.", registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.",
registerWithPasskey: "Mit Passkey registrieren", registerWithPasskey: "Mit Passkey registrieren",

View file

@ -265,6 +265,8 @@ export default {
backToPasskey: "Back to passkey login", backToPasskey: "Back to passkey login",
checkEmail: "Check your email! We sent a login link to", checkEmail: "Check your email! We sent a login link to",
linkExpires: "The link expires in 15 minutes.", 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?", noAccount: "Don't have an account?",
registerDescription: "Register with a passkey — no password needed.", registerDescription: "Register with a passkey — no password needed.",
registerWithPasskey: "Register with Passkey", registerWithPasskey: "Register with Passkey",