diff --git a/actions/auth/register.ts b/actions/auth/register.ts index c46dd57..daa61bf 100644 --- a/actions/auth/register.ts +++ b/actions/auth/register.ts @@ -138,7 +138,7 @@ export async function registerAndRedirect(input: RegisterInput): Promise { if (result.success) { if (process.env.E2E_SKIP_EMAIL_VERIFICATION === "true") { - redirect("/account"); + redirect("/account?setup=passkey"); } else { redirect("/verify-email?sent=true"); } diff --git a/app/account/page.tsx b/app/account/page.tsx index d43e641..6beec0c 100644 --- a/app/account/page.tsx +++ b/app/account/page.tsx @@ -3,6 +3,7 @@ import { db } from "@/lib/db"; import { users } from "@/lib/db/schema"; import { getSession } from "@/lib/auth/session"; import { ProfileForm } from "@/components/account/profile-form"; +import { PasskeySetupPrompt } from "@/components/account/passkey-setup-prompt"; import { PageHeader } from "@/components/dashboard"; import { Card, CardContent } from "@/components/ui/card"; import { eq } from "drizzle-orm"; @@ -12,7 +13,11 @@ export const metadata = { description: "Manage your profile settings", }; -export default async function AccountPage() { +export default async function AccountPage({ + searchParams, +}: { + searchParams: Promise<{ setup?: string }>; +}) { const session = await getSession(); if (!session.isLoggedIn || !session.userId) { @@ -27,6 +32,9 @@ export default async function AccountPage() { redirect("/login"); } + const params = await searchParams; + const showPasskeySetup = params.setup === "passkey"; + return (
+ + {showPasskeySetup && }
); } diff --git a/components/account/passkey-setup-prompt.tsx b/components/account/passkey-setup-prompt.tsx new file mode 100644 index 0000000..07f5099 --- /dev/null +++ b/components/account/passkey-setup-prompt.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { motion, AnimatePresence } from "framer-motion"; +import { Loader2, KeyRound, ShieldCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { usePasskey } from "@/hooks/use-passkey"; + +export function PasskeySetupPrompt() { + const router = useRouter(); + const [isOpen, setIsOpen] = useState(true); + const [name, setName] = useState(""); + const inputRef = useRef(null); + + const { isLoading, error, registerPasskey, clearError } = usePasskey({ + onSuccess: () => { + setIsOpen(false); + router.replace("/account"); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + await registerPasskey(name.trim()); + }; + + const handleSkip = useCallback(() => { + if (isLoading) return; + setIsOpen(false); + clearError(); + router.replace("/account"); + }, [clearError, router, isLoading]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && isOpen && !isLoading) { + handleSkip(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, isLoading, handleSkip]); + + // Focus the passkey name input when the dialog opens + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isOpen]); + + return ( + + {isOpen && ( + <> + + +
+
+ +
+
+

Secure your account

+

+ Add a passkey for faster, more secure sign-in +

+
+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setName(e.target.value)} + disabled={isLoading} + data-testid="setup-passkey-name" + /> +

+ Give your passkey a name to identify it later +

+
+ +
+ + +
+
+
+ + )} +
+ ); +} diff --git a/components/auth/register-form.tsx b/components/auth/register-form.tsx index d4f8e10..284dc16 100644 --- a/components/auth/register-form.tsx +++ b/components/auth/register-form.tsx @@ -36,7 +36,8 @@ export function RegisterForm({ whitelistOnly = false }: RegisterFormProps) { const result = await register({ email, password, displayName: displayName || undefined }); if (result.success) { if (process.env.NEXT_PUBLIC_E2E_SKIP_EMAIL_VERIFICATION === "true") { - router.push(redirectTo); + const target = redirectTo === "/account" ? "/account?setup=passkey" : redirectTo; + router.push(target); } else { router.push("/verify-email?sent=true"); } diff --git a/e2e/admin/sign-in-permission.spec.ts b/e2e/admin/sign-in-permission.spec.ts index a86aa6f..29530cb 100644 --- a/e2e/admin/sign-in-permission.spec.ts +++ b/e2e/admin/sign-in-permission.spec.ts @@ -160,7 +160,7 @@ test.describe("Client Sign-in Permission", () => { await page.getByLabel("Email").fill(testUser.email); await page.getByLabel("Password").fill(testUser.password); await page.getByRole("button", { name: "Create account" }).click(); - await expect(page).toHaveURL("/account"); + await expect(page).toHaveURL("/account?setup=passkey"); await page.close(); await context.close(); diff --git a/e2e/admin/signup-settings.spec.ts b/e2e/admin/signup-settings.spec.ts index 0a95643..d464bd0 100644 --- a/e2e/admin/signup-settings.spec.ts +++ b/e2e/admin/signup-settings.spec.ts @@ -67,8 +67,8 @@ test.describe("Sign-up Settings", () => { await page.getByLabel("Password").fill("TestPassword123!"); await page.getByRole("button", { name: "Create account" }).click(); - // Should redirect to account page - await expect(page).toHaveURL("/account"); + // Should redirect to account page with passkey setup prompt + await expect(page).toHaveURL("/account?setup=passkey"); }); test("should block non-whitelisted email when whitelist is enabled", async ({ @@ -146,8 +146,8 @@ test.describe("Sign-up Settings", () => { await page.getByLabel("Password").fill("TestPassword123!"); await page.getByRole("button", { name: "Create account" }).click(); - // Should redirect to account page - await expect(page).toHaveURL("/account"); + // Should redirect to account page with passkey setup prompt + await expect(page).toHaveURL("/account?setup=passkey"); }); test("should update register page immediately after settings change", async ({ diff --git a/e2e/auth/delete-account.spec.ts b/e2e/auth/delete-account.spec.ts index c1ddec5..49fdcb1 100644 --- a/e2e/auth/delete-account.spec.ts +++ b/e2e/auth/delete-account.spec.ts @@ -10,7 +10,7 @@ test.describe("Account Deletion", () => { await page.getByLabel("Email").fill(uniqueEmail); await page.getByLabel("Password").fill("TestPassword123!"); await page.getByRole("button", { name: "Create account" }).click(); - await expect(page).toHaveURL("/account"); + await expect(page).toHaveURL("/account?setup=passkey"); // Verify session is active const sessionResponse = await page.request.get("/api/auth/session"); @@ -51,7 +51,7 @@ test.describe("Account Deletion", () => { await page.getByLabel("Email").fill(uniqueEmail); await page.getByLabel("Password").fill("TestPassword123!"); await page.getByRole("button", { name: "Create account" }).click(); - await expect(page).toHaveURL("/account"); + await expect(page).toHaveURL("/account?setup=passkey"); // Delete account via API const deleteResponse = await page.request.delete("/api/auth/delete-account"); diff --git a/e2e/auth/login.spec.ts b/e2e/auth/login.spec.ts index 9f0aeb7..cb4e42c 100644 --- a/e2e/auth/login.spec.ts +++ b/e2e/auth/login.spec.ts @@ -18,7 +18,7 @@ test.describe("User Login", () => { await page.getByLabel("Email").fill(testUser.email); await page.getByLabel("Password").fill(testUser.password); await page.getByRole("button", { name: "Create account" }).click(); - await expect(page).toHaveURL("/account"); + await expect(page).toHaveURL("/account?setup=passkey"); await page.close(); }); diff --git a/e2e/auth/passkey-setup.spec.ts b/e2e/auth/passkey-setup.spec.ts new file mode 100644 index 0000000..e8a8eef --- /dev/null +++ b/e2e/auth/passkey-setup.spec.ts @@ -0,0 +1,142 @@ +import { test, expect } from "@playwright/test"; +import { setupWebAuthn } from "../fixtures/webauthn"; + +test.describe("Passkey Setup Prompt After Signup", () => { + test("should show passkey setup prompt after registration", async ( + { page }, + testInfo + ) => { + const uniqueEmail = `setup-prompt-${Date.now()}-${testInfo.parallelIndex}@example.com`; + + await page.goto("/register"); + await page.getByLabel("Display Name").fill("Prompt Test User"); + await page.getByLabel("Email").fill(uniqueEmail); + await page.getByLabel("Password").fill("TestPassword123!"); + await page.getByRole("button", { name: "Create account" }).click(); + + // Should redirect to account page with setup=passkey param + await expect(page).toHaveURL("/account?setup=passkey"); + + // Passkey setup prompt should be visible + await expect(page.getByTestId("passkey-setup-prompt")).toBeVisible(); + await expect(page.getByText("Secure your account")).toBeVisible(); + await expect( + page.getByText("Add a passkey for faster, more secure sign-in") + ).toBeVisible(); + await expect(page.getByTestId("setup-passkey-name")).toBeVisible(); + await expect(page.getByTestId("skip-passkey-setup")).toBeVisible(); + await expect(page.getByTestId("register-passkey-setup")).toBeVisible(); + }); + + test("should allow skipping passkey setup", async ({ page }, testInfo) => { + const uniqueEmail = `setup-skip-${Date.now()}-${testInfo.parallelIndex}@example.com`; + + await page.goto("/register"); + await page.getByLabel("Display Name").fill("Skip Test User"); + await page.getByLabel("Email").fill(uniqueEmail); + await page.getByLabel("Password").fill("TestPassword123!"); + await page.getByRole("button", { name: "Create account" }).click(); + + await expect(page).toHaveURL("/account?setup=passkey"); + await expect(page.getByTestId("passkey-setup-prompt")).toBeVisible(); + + // Click skip + await page.getByTestId("skip-passkey-setup").click(); + + // Prompt should disappear and URL should change to /account + await expect(page).toHaveURL("/account"); + await expect(page.getByTestId("passkey-setup-prompt")).not.toBeVisible(); + }); + + test("should not show passkey setup prompt on normal login", async ( + { page }, + testInfo + ) => { + const uniqueEmail = `setup-login-${Date.now()}-${testInfo.parallelIndex}@example.com`; + + // Register first + await page.goto("/register"); + await page.getByLabel("Display Name").fill("Login Test User"); + await page.getByLabel("Email").fill(uniqueEmail); + await page.getByLabel("Password").fill("TestPassword123!"); + await page.getByRole("button", { name: "Create account" }).click(); + await expect(page).toHaveURL("/account?setup=passkey"); + + // Skip the prompt + await page.getByTestId("skip-passkey-setup").click(); + await expect(page).toHaveURL("/account"); + + // Logout + await page.getByRole("button", { name: "Avatar" }).click(); + await page.getByRole("button", { name: /sign out/i }).click(); + await expect(page).toHaveURL("/login"); + + // Login again + await page.goto("/login"); + await page.getByLabel("Email").fill(uniqueEmail); + await page.getByLabel("Password").fill("TestPassword123!"); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + + // Should go to /account without setup param + await expect(page).toHaveURL("/account"); + + // Prompt should NOT be visible + await expect(page.getByTestId("passkey-setup-prompt")).not.toBeVisible(); + }); +}); + +test.describe("Passkey Setup with Virtual Authenticator", () => { + test.skip( + ({ browserName }) => browserName !== "chromium", + "WebAuthn tests only run in Chromium" + ); + + test("should register passkey via setup prompt after signup", async ( + { browser }, + testInfo + ) => { + const testUser = { + email: `setup-virtual-${Date.now()}-${testInfo.parallelIndex}@example.com`, + password: "TestPassword123!", + displayName: "Setup Passkey User", + }; + + const context = await browser.newContext(); + + try { + const page = await context.newPage(); + const { cdpSession } = await setupWebAuthn(page); + + // Register user + await page.goto("/register"); + await page.getByLabel("Display Name").fill(testUser.displayName); + await page.getByLabel("Email").fill(testUser.email); + await page.getByLabel("Password").fill("TestPassword123!"); + await page.getByRole("button", { name: "Create account" }).click(); + + // Should show passkey setup prompt + await expect(page).toHaveURL("/account?setup=passkey"); + await expect(page.getByTestId("passkey-setup-prompt")).toBeVisible(); + + // Fill in passkey name and register + await page.getByTestId("setup-passkey-name").fill("My First Passkey"); + await page.getByTestId("register-passkey-setup").click(); + + // Should redirect to /account after successful registration + await expect(page).toHaveURL("/account", { timeout: 10000 }); + await expect( + page.getByTestId("passkey-setup-prompt") + ).not.toBeVisible(); + + // Verify passkey was registered by navigating to passkeys page + await page.goto("/account/passkeys"); + await expect(page.getByText("My First Passkey")).toBeVisible({ + timeout: 10000, + }); + + await page.close(); + } finally { + await context.close(); + } + }); +}); diff --git a/e2e/auth/passkey.spec.ts b/e2e/auth/passkey.spec.ts index fba7554..888ae7a 100644 --- a/e2e/auth/passkey.spec.ts +++ b/e2e/auth/passkey.spec.ts @@ -1,20 +1,5 @@ -import { test, expect, Page, CDPSession } from "@playwright/test"; - -// Helper to set up WebAuthn virtual authenticator -async function setupWebAuthn(page: Page): Promise<{ cdpSession: CDPSession; authenticatorId: string }> { - const cdpSession = await page.context().newCDPSession(page); - await cdpSession.send("WebAuthn.enable"); - const result = await cdpSession.send("WebAuthn.addVirtualAuthenticator", { - options: { - protocol: "ctap2", - transport: "internal", - hasResidentKey: true, - hasUserVerification: true, - isUserVerified: true, - }, - }); - return { cdpSession, authenticatorId: result.authenticatorId }; -} +import { test, expect } from "@playwright/test"; +import { setupWebAuthn } from "../fixtures/webauthn"; test.describe("Passkey Authentication", () => { let testUser: { email: string; password: string; displayName: string }; @@ -33,7 +18,7 @@ test.describe("Passkey Authentication", () => { await page.getByLabel("Email").fill(testUser.email); await page.getByLabel("Password").fill(testUser.password); await page.getByRole("button", { name: "Create account" }).click(); - await expect(page).toHaveURL("/account"); + await expect(page).toHaveURL("/account?setup=passkey"); await page.close(); }); @@ -115,7 +100,7 @@ test.describe("Passkey with Virtual Authenticator", () => { await page.getByLabel("Email").fill(testUser.email); await page.getByLabel("Password").fill(testUser.password); await page.getByRole("button", { name: "Create account" }).click(); - await expect(page).toHaveURL("/account"); + await expect(page).toHaveURL("/account?setup=passkey"); // Navigate to passkeys await page.goto("/account/passkeys"); diff --git a/e2e/auth/password-reset.spec.ts b/e2e/auth/password-reset.spec.ts index ca6b6ea..3d72c8a 100644 --- a/e2e/auth/password-reset.spec.ts +++ b/e2e/auth/password-reset.spec.ts @@ -18,7 +18,7 @@ test.describe("Password Reset - Single Use Token", () => { await page.getByLabel("Email").fill(testUser.email); await page.getByLabel("Password").fill(testUser.password); await page.getByRole("button", { name: "Create account" }).click(); - await expect(page).toHaveURL("/account"); + await expect(page).toHaveURL("/account?setup=passkey"); await page.close(); }); diff --git a/e2e/auth/register.spec.ts b/e2e/auth/register.spec.ts index b921a2d..7d42155 100644 --- a/e2e/auth/register.spec.ts +++ b/e2e/auth/register.spec.ts @@ -22,8 +22,8 @@ test.describe("User Registration", () => { await page.getByRole("button", { name: "Create account" }).click(); - // Should redirect to account page (email verification skipped in E2E) - await expect(page).toHaveURL("/account"); + // Should redirect to account page with passkey setup prompt (email verification skipped in E2E) + await expect(page).toHaveURL("/account?setup=passkey"); }); @@ -36,6 +36,10 @@ test.describe("User Registration", () => { await page.getByLabel("Email").fill(uniqueEmail); await page.getByLabel("Password").fill("TestPassword123!"); await page.getByRole("button", { name: "Create account" }).click(); + await expect(page).toHaveURL("/account?setup=passkey"); + + // Dismiss passkey setup prompt + await page.getByTestId("skip-passkey-setup").click(); await expect(page).toHaveURL("/account"); // Logout and wait for redirect to login diff --git a/e2e/fixtures/test-helpers.ts b/e2e/fixtures/test-helpers.ts index 2213434..f28cb19 100644 --- a/e2e/fixtures/test-helpers.ts +++ b/e2e/fixtures/test-helpers.ts @@ -15,8 +15,13 @@ export async function registerUser(page: Page, user = testUser) { await page.getByRole("button", { name: "Create account" }).click(); - // Should redirect to account page (email verification skipped in E2E) - await expect(page).toHaveURL("/account", { timeout: 20000 }); + // Should redirect to account page with passkey setup prompt (email verification skipped in E2E) + await expect(page).toHaveURL("/account?setup=passkey", { timeout: 20000 }); + + // Dismiss the passkey setup prompt so subsequent interactions aren't blocked + const skipButton = page.getByTestId("skip-passkey-setup"); + await skipButton.click(); + await expect(page).toHaveURL("/account", { timeout: 10000 }); } export async function loginUser(page: Page, user = testUser) { diff --git a/e2e/fixtures/webauthn.ts b/e2e/fixtures/webauthn.ts new file mode 100644 index 0000000..25af8fd --- /dev/null +++ b/e2e/fixtures/webauthn.ts @@ -0,0 +1,19 @@ +import { Page, CDPSession } from "@playwright/test"; + +// Helper to set up WebAuthn virtual authenticator +export async function setupWebAuthn( + page: Page +): Promise<{ cdpSession: CDPSession; authenticatorId: string }> { + const cdpSession = await page.context().newCDPSession(page); + await cdpSession.send("WebAuthn.enable"); + const result = await cdpSession.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + }, + }); + return { cdpSession, authenticatorId: result.authenticatorId }; +} diff --git a/e2e/oauth/account-select.spec.ts b/e2e/oauth/account-select.spec.ts index e5c3071..5dcfd41 100644 --- a/e2e/oauth/account-select.spec.ts +++ b/e2e/oauth/account-select.spec.ts @@ -61,6 +61,10 @@ test.describe("OAuth Account Selection", () => { await page.getByLabel("Email").fill(user.email); await page.getByLabel("Password").fill(user.password); await page.getByRole("button", { name: "Create account" }).click(); + await expect(page).toHaveURL("/account?setup=passkey"); + + // Dismiss passkey setup prompt + await page.getByTestId("skip-passkey-setup").click(); await expect(page).toHaveURL("/account"); // Logout and wait for redirect to login page @@ -104,6 +108,10 @@ test.describe("OAuth Account Selection", () => { await page.getByLabel("Email").fill(user.email); await page.getByLabel("Password").fill(user.password); await page.getByRole("button", { name: "Create account" }).click(); + await expect(page).toHaveURL("/account?setup=passkey"); + + // Dismiss passkey setup prompt + await page.getByTestId("skip-passkey-setup").click(); await expect(page).toHaveURL("/account"); // Now start OAuth flow while already logged in @@ -137,6 +145,10 @@ test.describe("OAuth Account Selection", () => { await page.getByLabel("Email").fill(user.email); await page.getByLabel("Password").fill(user.password); await page.getByRole("button", { name: "Create account" }).click(); + await expect(page).toHaveURL("/account?setup=passkey"); + + // Dismiss passkey setup prompt + await page.getByTestId("skip-passkey-setup").click(); await expect(page).toHaveURL("/account"); // Start OAuth flow while logged in diff --git a/e2e/oauth/consent.spec.ts b/e2e/oauth/consent.spec.ts index 771ffe9..68cb1ed 100644 --- a/e2e/oauth/consent.spec.ts +++ b/e2e/oauth/consent.spec.ts @@ -25,6 +25,10 @@ test.describe("OAuth Consent Flow", () => { await page.getByLabel("Email").fill(testUser.email); await page.getByLabel("Password").fill(testUser.password); await page.getByRole("button", { name: "Create account" }).click(); + await expect(page).toHaveURL("/account?setup=passkey"); + + // Dismiss passkey setup prompt + await page.getByTestId("skip-passkey-setup").click(); await expect(page).toHaveURL("/account"); // Logout @@ -164,7 +168,7 @@ test.describe("OAuth Consent Flow", () => { await page.getByLabel("Email").fill(denyUser.email); await page.getByLabel("Password").fill(denyUser.password); await page.getByRole("button", { name: "Create account" }).click(); - await expect(page).toHaveURL("/account"); + await expect(page).toHaveURL("/account?setup=passkey"); // Request authorization const state = `deny-state-${Date.now()}`; @@ -211,7 +215,7 @@ test.describe("OAuth Consent Flow", () => { await page.getByLabel("Email").fill(revokeUser.email); await page.getByLabel("Password").fill(revokeUser.password); await page.getByRole("button", { name: "Create account" }).click(); - await expect(page).toHaveURL("/account"); + await expect(page).toHaveURL("/account?setup=passkey"); // Request authorization and approve const codeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; diff --git a/e2e/oauth/public-client.spec.ts b/e2e/oauth/public-client.spec.ts index 8f44f86..d32034a 100644 --- a/e2e/oauth/public-client.spec.ts +++ b/e2e/oauth/public-client.spec.ts @@ -33,6 +33,10 @@ test.describe("OAuth Public Client Flow", () => { await page.getByLabel("Email").fill(testUser.email); await page.getByLabel("Password").fill(testUser.password); await page.getByRole("button", { name: "Create account" }).click(); + await expect(page).toHaveURL("/account?setup=passkey"); + + // Dismiss passkey setup prompt + await page.getByTestId("skip-passkey-setup").click(); await expect(page).toHaveURL("/account"); // Logout diff --git a/e2e/oauth/refresh-token.spec.ts b/e2e/oauth/refresh-token.spec.ts index 83af6cd..3ac597e 100644 --- a/e2e/oauth/refresh-token.spec.ts +++ b/e2e/oauth/refresh-token.spec.ts @@ -34,6 +34,10 @@ test.describe("OAuth Refresh Token Flow", () => { await page.getByLabel("Email").fill(testUser.email); await page.getByLabel("Password").fill(testUser.password); await page.getByRole("button", { name: "Create account" }).click(); + await expect(page).toHaveURL("/account?setup=passkey"); + + // Dismiss passkey setup prompt + await page.getByTestId("skip-passkey-setup").click(); await expect(page).toHaveURL("/account"); // Logout diff --git a/e2e/oauth/user-id-consistency.spec.ts b/e2e/oauth/user-id-consistency.spec.ts index ac4298a..43dafea 100644 --- a/e2e/oauth/user-id-consistency.spec.ts +++ b/e2e/oauth/user-id-consistency.spec.ts @@ -35,6 +35,10 @@ test.describe("User ID Consistency", () => { await page.getByLabel("Email").fill(testUser.email); await page.getByLabel("Password").fill(testUser.password); await page.getByRole("button", { name: "Create account" }).click(); + await expect(page).toHaveURL("/account?setup=passkey"); + + // Dismiss passkey setup prompt + await page.getByTestId("skip-passkey-setup").click(); await expect(page).toHaveURL("/account"); // Logout diff --git a/e2e/oauth/wildcard-redirect.spec.ts b/e2e/oauth/wildcard-redirect.spec.ts index 7633514..6a165c2 100644 --- a/e2e/oauth/wildcard-redirect.spec.ts +++ b/e2e/oauth/wildcard-redirect.spec.ts @@ -34,6 +34,10 @@ test.describe("OAuth Wildcard Redirect URI", () => { await page.getByLabel("Email").fill(testUser.email); await page.getByLabel("Password").fill(testUser.password); await page.getByRole("button", { name: "Create account" }).click(); + await expect(page).toHaveURL("/account?setup=passkey"); + + // Dismiss passkey setup prompt + await page.getByTestId("skip-passkey-setup").click(); await expect(page).toHaveURL("/account"); // Logout