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

@ -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 });

View file

@ -17,6 +17,7 @@ export default function LoginPage() {
});
}, []);
const [email, setEmail] = useState("");
const [loginCode, setLoginCode] = useState("");
const [error, setError] = useState<string | null>(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 && (
<div className="mt-8 rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
{t("auth.checkEmail")} <strong>{email}</strong>.
</p>
<p className="mt-2 text-xs text-green-600">
{t("auth.linkExpires")}
</p>
<div className="mt-8 space-y-4">
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
{t("auth.checkEmail")} <strong>{email}</strong>.
</p>
<p className="mt-2 text-xs text-green-600">
{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>
)}