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>
176 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|