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:
parent
9c7891402f
commit
9adf9b977a
7 changed files with 199 additions and 30 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue