diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index a85a55e..6d93276 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -144,6 +144,36 @@ export async function addPasskeyFinish( }); } +// --- Registration via Magic Link (no passkey) --- + +export async function registerWithMagicLink(email: string, username: string): Promise { + 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() { diff --git a/apps/journal/app/routes/api.auth.register.ts b/apps/journal/app/routes/api.auth.register.ts index b99c252..debd278 100644 --- a/apps/journal/app/routes/api.auth.register.ts +++ b/apps/journal/app/routes/api.auth.register.ts @@ -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 }); diff --git a/apps/journal/app/routes/auth.login.tsx b/apps/journal/app/routes/auth.login.tsx index eaa0ed1..28a9ced 100644 --- a/apps/journal/app/routes/auth.login.tsx +++ b/apps/journal/app/routes/auth.login.tsx @@ -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(null); const [loading, setLoading] = useState(false); @@ -90,7 +99,7 @@ export default function LoginPage() {

{t("auth.login")}

- {mode === "passkey" && ( + {mode === "passkey" && supportsPasskey && (
-
+ {supportsPasskey && ( +
+ +
+ )} )} diff --git a/apps/journal/app/routes/auth.register.tsx b/apps/journal/app/routes/auth.register.tsx index 6db6cd3..90ea6b1 100644 --- a/apps/journal/app/routes/auth.register.tsx +++ b/apps/journal/app/routes/auth.register.tsx @@ -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(null); const [loading, setLoading] = useState(false); + const [supportsPasskey, setSupportsPasskey] = useState(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 ( +
+

{t("auth.register")}

+
+

+ {t("auth.checkEmail")} {email}. +

+

+ {t("auth.linkExpires")} +

+
+
+ ); + } + return (

{t("auth.register")}

@@ -66,7 +114,7 @@ export default function RegisterPage() { {t("auth.registerDescription")}

-
+
) : (
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 9293b57..aee50db 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -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; diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 7921d09..41f246c 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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;