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:
parent
2a94af5655
commit
3bcb1fce7c
7 changed files with 130 additions and 20 deletions
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue