trails/apps/journal/app/routes/auth.login.tsx
Ullrich Schäfer dd6c59f2d0
Fix passkey credential ID encoding and improve error handling
Bug: @simplewebauthn v13 changed credential.id from Uint8Array to
base64url string. Buffer.from(string) without encoding stored the
ASCII text instead of decoded bytes. Authentication then failed to
match because it correctly decoded with "base64url".

Fix: Buffer.from(credential.id, "base64url") in both registration
and add-passkey flows.

Also: catch WebAuthn "not allowed" errors and show a friendly message
instead of the raw browser error.

Existing passkeys must be re-registered after deploy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:32:57 +01:00

176 lines
5.4 KiB
TypeScript

import { useState } from "react";
import { useTranslation } from "react-i18next";
export default function LoginPage() {
const { t } = useTranslation("journal");
const [mode, setMode] = useState<"passkey" | "magic-link">("passkey");
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [magicLinkSent, setMagicLinkSent] = useState(false);
const handlePasskeyLogin = async () => {
setError(null);
setLoading(true);
try {
const startResp = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: "start-passkey" }),
});
const startData = await startResp.json();
if (startData.error) {
setError(startData.error);
setLoading(false);
return;
}
const { startAuthentication: startWebAuthn } = await import("@simplewebauthn/browser");
const webAuthnResp = await startWebAuthn(startData.options);
const finishResp = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
step: "finish-passkey",
response: webAuthnResp,
challenge: startData.options.challenge,
}),
});
const finishData = await finishResp.json();
if (finishData.error) {
setError(finishData.error);
} else if (finishData.step === "done") {
window.location.href = "/";
}
} catch (err) {
const message = (err as Error).message;
if (message.includes("timed out") || message.includes("not allowed")) {
setError("No passkey found for this site. Register a new account or use a magic link instead.");
} else {
setError(message);
}
} finally {
setLoading(false);
}
};
const handleMagicLink = 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: "magic-link", email }),
});
const result = await resp.json();
if (result.error) {
setError(result.error);
} else if (result.devLink) {
// Dev mode: auto-redirect to magic link
window.location.href = result.devLink;
} else {
setMagicLinkSent(true);
}
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
return (
<div className="mx-auto max-w-md px-4 py-16">
<h1 className="text-2xl font-bold text-gray-900">{t("auth.login")}</h1>
{mode === "passkey" && (
<div className="mt-8">
<button
onClick={handlePasskeyLogin}
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-3 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? t("auth.authenticating") : t("auth.signInWithPasskey")}
</button>
<div className="mt-6 text-center">
<button
onClick={() => setMode("magic-link")}
className="text-sm text-gray-500 hover:text-gray-700"
>
{t("auth.useMagicLink")}
</button>
</div>
</div>
)}
{mode === "magic-link" && !magicLinkSent && (
<form onSubmit={handleMagicLink} className="mt-8 space-y-4">
<p className="text-sm text-gray-600">
{t("auth.magicLinkHelp")}
</p>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
{t("auth.email")}
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-gray-800 px-4 py-2 text-white hover:bg-gray-900 disabled:opacity-50"
>
{loading ? t("auth.sending") : t("auth.sendMagicLink")}
</button>
<div className="text-center">
<button
type="button"
onClick={() => setMode("passkey")}
className="text-sm text-gray-500 hover:text-gray-700"
>
{t("auth.backToPasskey")}
</button>
</div>
</form>
)}
{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>
)}
{error && (
<p className="mt-4 text-sm text-red-600">{error}</p>
)}
<p className="mt-6 text-center text-sm text-gray-500">
{t("auth.noAccount")}{" "}
<a href="/auth/register" className="text-blue-600 hover:underline">
{t("auth.register")}
</a>
</p>
</div>
);
}