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 ---
|
// --- Passkey Login ---
|
||||||
|
|
||||||
export async function startAuthentication() {
|
export async function startAuthentication() {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { data } from "react-router";
|
import { data } from "react-router";
|
||||||
import type { Route } from "./+types/api.auth.register";
|
import type { Route } from "./+types/api.auth.register";
|
||||||
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish } from "~/lib/auth.server";
|
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish, registerWithMagicLink } from "~/lib/auth.server";
|
||||||
import { sendWelcome } from "~/lib/email.server";
|
import { sendWelcome, sendMagicLink } from "~/lib/email.server";
|
||||||
import { logger } from "~/lib/logger.server";
|
import { logger } from "~/lib/logger.server";
|
||||||
|
|
||||||
export async function action({ request }: Route.ActionArgs) {
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { step, email, username, response, challenge, userId } = body;
|
const { step, email, username, response, challenge, userId } = body;
|
||||||
|
const origin = process.env.ORIGIN ?? `http://localhost:3000`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (step === "start") {
|
if (step === "start") {
|
||||||
|
|
@ -17,13 +18,25 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
if (step === "finish") {
|
if (step === "finish") {
|
||||||
const newUserId = await finishRegistration(userId, email, username, response, challenge);
|
const newUserId = await finishRegistration(userId, email, username, response, challenge);
|
||||||
const cookie = await createSession(newUserId, request);
|
const cookie = await createSession(newUserId, request);
|
||||||
// Send welcome email (fire-and-forget — don't block registration on email)
|
|
||||||
sendWelcome(email, username).catch((err) =>
|
sendWelcome(email, username).catch((err) =>
|
||||||
logger.error({ err }, "Failed to send welcome email"),
|
logger.error({ err }, "Failed to send welcome email"),
|
||||||
);
|
);
|
||||||
return data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
|
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") {
|
if (step === "add-passkey") {
|
||||||
const options = await addPasskeyStart(userId);
|
const options = await addPasskeyStart(userId);
|
||||||
return data({ step: "challenge", options });
|
return data({ step: "challenge", options });
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { t } = useTranslation("journal");
|
const { t } = useTranslation("journal");
|
||||||
|
const [supportsPasskey, setSupportsPasskey] = useState(true);
|
||||||
const [mode, setMode] = useState<"passkey" | "magic-link">("passkey");
|
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 [email, setEmail] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -90,7 +99,7 @@ export default function LoginPage() {
|
||||||
<div className="mx-auto max-w-md px-4 py-16">
|
<div className="mx-auto max-w-md px-4 py-16">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{t("auth.login")}</h1>
|
<h1 className="text-2xl font-bold text-gray-900">{t("auth.login")}</h1>
|
||||||
|
|
||||||
{mode === "passkey" && (
|
{mode === "passkey" && supportsPasskey && (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<button
|
<button
|
||||||
onClick={handlePasskeyLogin}
|
onClick={handlePasskeyLogin}
|
||||||
|
|
@ -138,15 +147,17 @@ export default function LoginPage() {
|
||||||
{loading ? t("auth.sending") : t("auth.sendMagicLink")}
|
{loading ? t("auth.sending") : t("auth.sendMagicLink")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="text-center">
|
{supportsPasskey && (
|
||||||
<button
|
<div className="text-center">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setMode("passkey")}
|
type="button"
|
||||||
className="text-sm text-gray-500 hover:text-gray-700"
|
onClick={() => setMode("passkey")}
|
||||||
>
|
className="text-sm text-gray-500 hover:text-gray-700"
|
||||||
{t("auth.backToPasskey")}
|
>
|
||||||
</button>
|
{t("auth.backToPasskey")}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
|
|
@ -7,14 +7,21 @@ export default function RegisterPage() {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get registration options from server
|
|
||||||
const startResp = await fetch("/api/auth/register", {
|
const startResp = await fetch("/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -28,11 +35,9 @@ export default function RegisterPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Create passkey via browser
|
|
||||||
const { startRegistration: startWebAuthn } = await import("@simplewebauthn/browser");
|
const { startRegistration: startWebAuthn } = await import("@simplewebauthn/browser");
|
||||||
const webAuthnResp = await startWebAuthn(startData.options);
|
const webAuthnResp = await startWebAuthn(startData.options);
|
||||||
|
|
||||||
// Step 3: Send response to server
|
|
||||||
const finishResp = await fetch("/api/auth/register", {
|
const finishResp = await fetch("/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-md px-4 py-16">
|
<div className="mx-auto max-w-md px-4 py-16">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{t("auth.register")}</h1>
|
<h1 className="text-2xl font-bold text-gray-900">{t("auth.register")}</h1>
|
||||||
|
|
@ -66,7 +114,7 @@ export default function RegisterPage() {
|
||||||
{t("auth.registerDescription")}
|
{t("auth.registerDescription")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleRegister} className="mt-8 space-y-4">
|
<form onSubmit={supportsPasskey ? handleRegisterPasskey : handleRegisterMagicLink} className="mt-8 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
{t("auth.email")}
|
{t("auth.email")}
|
||||||
|
|
@ -103,13 +151,29 @@ export default function RegisterPage() {
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
{supportsPasskey ? (
|
||||||
type="submit"
|
<button
|
||||||
disabled={loading}
|
type="submit"
|
||||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
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>
|
{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>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-gray-500">
|
<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 { data } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { eq, count } from "drizzle-orm";
|
||||||
import type { Route } from "./+types/home";
|
import type { Route } from "./+types/home";
|
||||||
import { getSessionUser } from "~/lib/auth.server";
|
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) {
|
export function meta(_args: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
|
|
@ -15,18 +18,39 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const user = await getSessionUser(request);
|
const user = await getSessionUser(request);
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const showAddPasskey = url.searchParams.get("add-passkey") === "1" && user !== null;
|
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({
|
return data({
|
||||||
user: user ? { id: user.id, username: user.username, displayName: user.displayName } : null,
|
user: user ? { id: user.id, username: user.username, displayName: user.displayName } : null,
|
||||||
showAddPasskey,
|
showAddPasskey,
|
||||||
|
passkeyCount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
const { user, showAddPasskey } = loaderData;
|
const { user, showAddPasskey, passkeyCount } = loaderData;
|
||||||
const { t } = useTranslation("journal");
|
const { t } = useTranslation("journal");
|
||||||
const [addingPasskey, setAddingPasskey] = useState(false);
|
const [addingPasskey, setAddingPasskey] = useState(false);
|
||||||
const [passkeyDone, setPasskeyDone] = useState(false);
|
const [passkeyDone, setPasskeyDone] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 () => {
|
const handleAddPasskey = useCallback(async () => {
|
||||||
if (!user) return;
|
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>
|
{t("welcome")} <a href={`/users/${user.username}`} className="text-blue-600 hover:underline">{user.displayName ?? user.username}</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{showAddPasskey && !passkeyDone && (
|
{showAddPasskey && !passkeyDone && supportsPasskey === true && (
|
||||||
<div className="mt-6 rounded-md bg-blue-50 p-4">
|
<div className="mt-6 rounded-md bg-blue-50 p-4">
|
||||||
<p className="text-sm text-blue-800">
|
<p className="text-sm text-blue-800">
|
||||||
{t("addPasskeyPrompt")}
|
{t("addPasskeyPrompt")}
|
||||||
|
|
@ -101,6 +125,17 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
</div>
|
</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 && (
|
{passkeyDone && (
|
||||||
<div className="mt-6 rounded-md bg-green-50 p-4">
|
<div className="mt-6 rounded-md bg-green-50 p-4">
|
||||||
<p className="text-sm text-green-800">
|
<p className="text-sm text-green-800">
|
||||||
|
|
@ -108,6 +143,12 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{user && passkeyCount > 0 && !showAddPasskey && (
|
||||||
|
<p className="mt-4 text-sm text-gray-500">
|
||||||
|
{t("passkeyStatus", { count: passkeyCount })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-8 flex gap-4">
|
<div className="mt-8 flex gap-4">
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,8 @@ export default {
|
||||||
addPasskey: "Passkey hinzufügen",
|
addPasskey: "Passkey hinzufügen",
|
||||||
settingUp: "Wird eingerichtet...",
|
settingUp: "Wird eingerichtet...",
|
||||||
passkeyAdded: "Passkey hinzugefügt! Du kannst dich jetzt sofort auf diesem Gerät anmelden.",
|
passkeyAdded: "Passkey hinzugefügt! Du kannst dich jetzt sofort auf diesem Gerät anmelden.",
|
||||||
|
passkeyStatus: "{{count}} Passkey registriert",
|
||||||
|
passkeyStatus_other: "{{count}} Passkeys registriert",
|
||||||
routes: {
|
routes: {
|
||||||
title: "Routen",
|
title: "Routen",
|
||||||
myRoutes: "Meine Routen",
|
myRoutes: "Meine Routen",
|
||||||
|
|
@ -147,6 +149,9 @@ export default {
|
||||||
registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.",
|
registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.",
|
||||||
registerWithPasskey: "Mit Passkey registrieren",
|
registerWithPasskey: "Mit Passkey registrieren",
|
||||||
creatingPasskey: "Erstelle Passkey...",
|
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;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,8 @@ export default {
|
||||||
addPasskey: "Add Passkey",
|
addPasskey: "Add Passkey",
|
||||||
settingUp: "Setting up...",
|
settingUp: "Setting up...",
|
||||||
passkeyAdded: "Passkey added! You can now sign in instantly on this device.",
|
passkeyAdded: "Passkey added! You can now sign in instantly on this device.",
|
||||||
|
passkeyStatus: "{{count}} passkey registered",
|
||||||
|
passkeyStatus_other: "{{count}} passkeys registered",
|
||||||
routes: {
|
routes: {
|
||||||
title: "Routes",
|
title: "Routes",
|
||||||
myRoutes: "My Routes",
|
myRoutes: "My Routes",
|
||||||
|
|
@ -147,6 +149,9 @@ export default {
|
||||||
registerDescription: "Register with a passkey — no password needed.",
|
registerDescription: "Register with a passkey — no password needed.",
|
||||||
registerWithPasskey: "Register with Passkey",
|
registerWithPasskey: "Register with Passkey",
|
||||||
creatingPasskey: "Creating 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;
|
} as const;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue