Complete Journal auth: add passkey prompt, dev magic link, fix validation
- Add passkey prompt after magic link login (/?add-passkey=1) - addPasskeyStart/addPasskeyFinish for existing users - Dev mode: magic link auto-redirects (returns devLink in response) - Fix username validation: strip invalid chars instead of HTML pattern - Separate API routes from page routes for JSON fetch compatibility All 10 Group 7 tasks complete. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6950fd7de5
commit
cc308bc17f
6 changed files with 157 additions and 5 deletions
|
|
@ -90,6 +90,60 @@ export async function finishRegistration(
|
|||
return userId;
|
||||
}
|
||||
|
||||
// --- Add Passkey to Existing Account ---
|
||||
|
||||
export async function addPasskeyStart(userId: string) {
|
||||
const db = getDb();
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: RP_NAME,
|
||||
rpID: RP_ID,
|
||||
userID: new TextEncoder().encode(userId),
|
||||
userName: user.username,
|
||||
userDisplayName: user.displayName ?? user.username,
|
||||
attestationType: "none",
|
||||
authenticatorSelection: {
|
||||
residentKey: "preferred",
|
||||
userVerification: "preferred",
|
||||
},
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function addPasskeyFinish(
|
||||
userId: string,
|
||||
response: RegistrationResponseJSON,
|
||||
challenge: string,
|
||||
) {
|
||||
const db = getDb();
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: ORIGIN,
|
||||
expectedRPID: RP_ID,
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
throw new Error("Passkey verification failed");
|
||||
}
|
||||
|
||||
const { credential } = verification.registrationInfo;
|
||||
|
||||
await db.insert(credentials).values({
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
credentialId: Buffer.from(credential.id),
|
||||
publicKey: Buffer.from(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
transports: response.response.transports,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Passkey Login ---
|
||||
|
||||
export async function startAuthentication() {
|
||||
|
|
|
|||
|
|
@ -20,8 +20,14 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
|
||||
if (step === "magic-link") {
|
||||
const token = await createMagicToken(email);
|
||||
const link = `${process.env.ORIGIN ?? "http://localhost:3000"}/auth/verify?token=${token}`;
|
||||
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
||||
const link = `${origin}/auth/verify?token=${token}`;
|
||||
console.log(`[Magic Link] ${email}: ${link}`);
|
||||
|
||||
// In dev, return the link directly so the client can auto-redirect
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return data({ step: "magic-link-sent", devLink: link });
|
||||
}
|
||||
return data({ step: "magic-link-sent" });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.auth.register";
|
||||
import { startRegistration, finishRegistration, createSession } from "~/lib/auth.server";
|
||||
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish } from "~/lib/auth.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const body = await request.json();
|
||||
|
|
@ -18,6 +18,16 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
return data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
|
||||
}
|
||||
|
||||
if (step === "add-passkey") {
|
||||
const options = await addPasskeyStart(userId);
|
||||
return data({ step: "challenge", options });
|
||||
}
|
||||
|
||||
if (step === "finish-add-passkey") {
|
||||
await addPasskeyFinish(userId, response, challenge);
|
||||
return data({ step: "done" });
|
||||
}
|
||||
|
||||
return data({ error: "Invalid step" }, { status: 400 });
|
||||
} catch (e) {
|
||||
return data({ error: (e as Error).message }, { status: 400 });
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ export default function LoginPage() {
|
|||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/home";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
|
|
@ -11,11 +12,65 @@ export function meta(_args: Route.MetaArgs) {
|
|||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const user = await getSessionUser(request);
|
||||
return data({ user: user ? { username: user.username, displayName: user.displayName } : null });
|
||||
const url = new URL(request.url);
|
||||
const showAddPasskey = url.searchParams.get("add-passkey") === "1" && user !== null;
|
||||
return data({
|
||||
user: user ? { id: user.id, username: user.username, displayName: user.displayName } : null,
|
||||
showAddPasskey,
|
||||
});
|
||||
}
|
||||
|
||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
const { user } = loaderData;
|
||||
const { user, showAddPasskey } = loaderData;
|
||||
const [addingPasskey, setAddingPasskey] = useState(false);
|
||||
const [passkeyDone, setPasskeyDone] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleAddPasskey = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setAddingPasskey(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get registration options for existing user
|
||||
const startResp = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step: "add-passkey", userId: user.id }),
|
||||
});
|
||||
const startData = await startResp.json();
|
||||
|
||||
if (startData.error) {
|
||||
setError(startData.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const { startRegistration } = await import("@simplewebauthn/browser");
|
||||
const webAuthnResp = await startRegistration(startData.options);
|
||||
|
||||
const finishResp = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
step: "finish-add-passkey",
|
||||
userId: user.id,
|
||||
response: webAuthnResp,
|
||||
challenge: startData.options.challenge,
|
||||
}),
|
||||
});
|
||||
|
||||
const finishData = await finishResp.json();
|
||||
if (finishData.error) {
|
||||
setError(finishData.error);
|
||||
} else {
|
||||
setPasskeyDone(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setAddingPasskey(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-16">
|
||||
|
|
@ -27,6 +82,30 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||
<p className="text-gray-700">
|
||||
Welcome, <a href={`/users/${user.username}`} className="text-blue-600 hover:underline">{user.displayName ?? user.username}</a>
|
||||
</p>
|
||||
|
||||
{showAddPasskey && !passkeyDone && (
|
||||
<div className="mt-6 rounded-md bg-blue-50 p-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
Add a passkey for faster sign-in on this device.
|
||||
</p>
|
||||
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
onClick={handleAddPasskey}
|
||||
disabled={addingPasskey}
|
||||
className="mt-3 rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{addingPasskey ? "Setting up..." : "Add Passkey"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{passkeyDone && (
|
||||
<div className="mt-6 rounded-md bg-green-50 p-4">
|
||||
<p className="text-sm text-green-800">
|
||||
Passkey added! You can now sign in instantly on this device.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 flex gap-4">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue