Passkey browser compatibility: detect support, magic link fallback

- Login: hide passkey button when browser lacks WebAuthn, default to
  magic link mode, hide "back to passkey" link
- Register: detect passkey support and offer magic link registration as
  fallback. New server-side registerWithMagicLink() creates account
  without credential and sends verification email.
- Home: show passkey count for logged-in users, show amber unsupported
  message instead of hiding the add-passkey prompt entirely
- i18n: add passkeyNotSupported, passkeyNotSupportedRegister,
  registerWithMagicLink, passkeyStatus keys (en + de)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-29 09:54:37 +02:00
parent 9c7891402f
commit 9adf9b977a
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
7 changed files with 199 additions and 30 deletions

View file

@ -144,6 +144,36 @@ export async function addPasskeyFinish(
});
}
// --- Registration via Magic Link (no passkey) ---
export async function registerWithMagicLink(email: string, username: string): Promise<string> {
const db = getDb();
const [existingEmail] = await db.select().from(users).where(eq(users.email, email));
if (existingEmail) throw new Error("Email already in use");
const [existingUsername] = await db.select().from(users).where(eq(users.username, username));
if (existingUsername) throw new Error("Username already taken");
const userId = randomUUID();
const domain = process.env.DOMAIN ?? "localhost";
await db.insert(users).values({ id: userId, email, username, domain });
// Create magic token for verification
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
await db.insert(magicTokens).values({
id: randomUUID(),
email,
token,
expiresAt,
});
return token;
}
// --- Passkey Login ---
export async function startAuthentication() {

View file

@ -1,12 +1,13 @@
import { data } from "react-router";
import type { Route } from "./+types/api.auth.register";
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish } from "~/lib/auth.server";
import { sendWelcome } from "~/lib/email.server";
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish, registerWithMagicLink } from "~/lib/auth.server";
import { sendWelcome, sendMagicLink } from "~/lib/email.server";
import { logger } from "~/lib/logger.server";
export async function action({ request }: Route.ActionArgs) {
const body = await request.json();
const { step, email, username, response, challenge, userId } = body;
const origin = process.env.ORIGIN ?? `http://localhost:3000`;
try {
if (step === "start") {
@ -17,13 +18,25 @@ export async function action({ request }: Route.ActionArgs) {
if (step === "finish") {
const newUserId = await finishRegistration(userId, email, username, response, challenge);
const cookie = await createSession(newUserId, request);
// Send welcome email (fire-and-forget — don't block registration on email)
sendWelcome(email, username).catch((err) =>
logger.error({ err }, "Failed to send welcome email"),
);
return data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
}
if (step === "register-magic-link") {
const token = await registerWithMagicLink(email, username);
const link = `${origin}/auth/verify?token=${token}`;
if (process.env.NODE_ENV !== "production") {
return data({ step: "magic-link-sent", devLink: link });
}
await sendMagicLink(email, link);
sendWelcome(email, username).catch((err) =>
logger.error({ err }, "Failed to send welcome email"),
);
return data({ step: "magic-link-sent" });
}
if (step === "add-passkey") {
const options = await addPasskeyStart(userId);
return data({ step: "challenge", options });

View file

@ -1,9 +1,18 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
export default function LoginPage() {
const { t } = useTranslation("journal");
const [supportsPasskey, setSupportsPasskey] = useState(true);
const [mode, setMode] = useState<"passkey" | "magic-link">("passkey");
useEffect(() => {
import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => {
const supported = browserSupportsWebAuthn();
setSupportsPasskey(supported);
if (!supported) setMode("magic-link");
});
}, []);
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@ -90,7 +99,7 @@ export default function LoginPage() {
<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" && (
{mode === "passkey" && supportsPasskey && (
<div className="mt-8">
<button
onClick={handlePasskeyLogin}
@ -138,15 +147,17 @@ export default function LoginPage() {
{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>
{supportsPasskey && (
<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>
)}

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
export default function RegisterPage() {
@ -7,14 +7,21 @@ export default function RegisterPage() {
const [username, setUsername] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [supportsPasskey, setSupportsPasskey] = useState<boolean | null>(null);
const [magicLinkSent, setMagicLinkSent] = useState(false);
const handleRegister = async (e: React.FormEvent) => {
useEffect(() => {
import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => {
setSupportsPasskey(browserSupportsWebAuthn());
});
}, []);
const handleRegisterPasskey = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
// Step 1: Get registration options from server
const startResp = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -28,11 +35,9 @@ export default function RegisterPage() {
return;
}
// Step 2: Create passkey via browser
const { startRegistration: startWebAuthn } = await import("@simplewebauthn/browser");
const webAuthnResp = await startWebAuthn(startData.options);
// Step 3: Send response to server
const finishResp = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -59,6 +64,49 @@ export default function RegisterPage() {
}
};
const handleRegisterMagicLink = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const resp = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: "register-magic-link", email, username }),
});
const result = await resp.json();
if (result.error) {
setError(result.error);
} else if (result.devLink) {
window.location.href = result.devLink;
} else {
setMagicLinkSent(true);
}
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
if (magicLinkSent) {
return (
<div className="mx-auto max-w-md px-4 py-16">
<h1 className="text-2xl font-bold text-gray-900">{t("auth.register")}</h1>
<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>
</div>
);
}
return (
<div className="mx-auto max-w-md px-4 py-16">
<h1 className="text-2xl font-bold text-gray-900">{t("auth.register")}</h1>
@ -66,7 +114,7 @@ export default function RegisterPage() {
{t("auth.registerDescription")}
</p>
<form onSubmit={handleRegister} className="mt-8 space-y-4">
<form onSubmit={supportsPasskey ? handleRegisterPasskey : handleRegisterMagicLink} className="mt-8 space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
{t("auth.email")}
@ -103,13 +151,29 @@ export default function RegisterPage() {
<p className="text-sm text-red-600">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? t("auth.creatingPasskey") : t("auth.registerWithPasskey")}
</button>
{supportsPasskey ? (
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? t("auth.creatingPasskey") : t("auth.registerWithPasskey")}
</button>
) : (
<button
type="submit"
disabled={loading || supportsPasskey === null}
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.registerWithMagicLink")}
</button>
)}
{supportsPasskey === false && (
<p className="text-sm text-gray-500">
{t("auth.passkeyNotSupportedRegister")}
</p>
)}
</form>
<p className="mt-6 text-center text-sm text-gray-500">

View file

@ -1,8 +1,11 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { data } from "react-router";
import { useTranslation } from "react-i18next";
import { eq, count } from "drizzle-orm";
import type { Route } from "./+types/home";
import { getSessionUser } from "~/lib/auth.server";
import { getDb } from "~/lib/db";
import { credentials } from "@trails-cool/db/schema/journal";
export function meta(_args: Route.MetaArgs) {
return [
@ -15,18 +18,39 @@ export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
const url = new URL(request.url);
const showAddPasskey = url.searchParams.get("add-passkey") === "1" && user !== null;
let passkeyCount = 0;
if (user) {
const db = getDb();
const [row] = await db
.select({ count: count() })
.from(credentials)
.where(eq(credentials.userId, user.id));
passkeyCount = row?.count ?? 0;
}
return data({
user: user ? { id: user.id, username: user.username, displayName: user.displayName } : null,
showAddPasskey,
passkeyCount,
});
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { user, showAddPasskey } = loaderData;
const { user, showAddPasskey, passkeyCount } = loaderData;
const { t } = useTranslation("journal");
const [addingPasskey, setAddingPasskey] = useState(false);
const [passkeyDone, setPasskeyDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [supportsPasskey, setSupportsPasskey] = useState<boolean | null>(null);
useEffect(() => {
if (showAddPasskey) {
import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => {
setSupportsPasskey(browserSupportsWebAuthn());
});
}
}, [showAddPasskey]);
const handleAddPasskey = useCallback(async () => {
if (!user) return;
@ -85,7 +109,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
{t("welcome")} <a href={`/users/${user.username}`} className="text-blue-600 hover:underline">{user.displayName ?? user.username}</a>
</p>
{showAddPasskey && !passkeyDone && (
{showAddPasskey && !passkeyDone && supportsPasskey === true && (
<div className="mt-6 rounded-md bg-blue-50 p-4">
<p className="text-sm text-blue-800">
{t("addPasskeyPrompt")}
@ -101,6 +125,17 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</div>
)}
{showAddPasskey && !passkeyDone && supportsPasskey === false && (
<div className="mt-6 rounded-md bg-amber-50 p-4">
<p className="text-sm text-amber-800">
{t("addPasskeyPrompt")}
</p>
<p className="mt-2 text-sm text-amber-600">
{t("auth.passkeyNotSupported")}
</p>
</div>
)}
{passkeyDone && (
<div className="mt-6 rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
@ -108,6 +143,12 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</p>
</div>
)}
{user && passkeyCount > 0 && !showAddPasskey && (
<p className="mt-4 text-sm text-gray-500">
{t("passkeyStatus", { count: passkeyCount })}
</p>
)}
</div>
) : (
<div className="mt-8 flex gap-4">

View file

@ -106,6 +106,8 @@ export default {
addPasskey: "Passkey hinzufügen",
settingUp: "Wird eingerichtet...",
passkeyAdded: "Passkey hinzugefügt! Du kannst dich jetzt sofort auf diesem Gerät anmelden.",
passkeyStatus: "{{count}} Passkey registriert",
passkeyStatus_other: "{{count}} Passkeys registriert",
routes: {
title: "Routen",
myRoutes: "Meine Routen",
@ -147,6 +149,9 @@ export default {
registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.",
registerWithPasskey: "Mit Passkey registrieren",
creatingPasskey: "Erstelle Passkey...",
passkeyNotSupported: "Dein Browser unterstützt keine Passkeys. Bitte verwende einen anderen Browser zur Registrierung.",
passkeyNotSupportedRegister: "Dein Browser unterstützt keine Passkeys. Du kannst dich stattdessen per Magic Link registrieren und später einen Passkey hinzufügen.",
registerWithMagicLink: "Per Magic Link registrieren",
},
},
} as const;

View file

@ -106,6 +106,8 @@ export default {
addPasskey: "Add Passkey",
settingUp: "Setting up...",
passkeyAdded: "Passkey added! You can now sign in instantly on this device.",
passkeyStatus: "{{count}} passkey registered",
passkeyStatus_other: "{{count}} passkeys registered",
routes: {
title: "Routes",
myRoutes: "My Routes",
@ -147,6 +149,9 @@ export default {
registerDescription: "Register with a passkey — no password needed.",
registerWithPasskey: "Register with Passkey",
creatingPasskey: "Creating passkey...",
passkeyNotSupported: "Your browser does not support passkeys. Please use a different browser to register.",
passkeyNotSupportedRegister: "Your browser doesn't support passkeys. You can register with a magic link instead and add a passkey later from a supported browser.",
registerWithMagicLink: "Register with Magic Link",
},
},
} as const;