From e5ca0c47256a7e4fdbc4313dae4fed041dd94769 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:16:43 +0000 Subject: [PATCH 1/7] Initial plan From 5703d7203b8b55324a7965fe2165d24d845735db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:25:41 +0000 Subject: [PATCH 2/7] feat: prompt passkey setup after first-time signup with E2E tests - Add PasskeySetupPrompt component that auto-shows after registration - Update registration redirect to include ?setup=passkey query param - Update account page to conditionally render the prompt - Add comprehensive E2E tests for passkey setup prompt flow - Update all existing E2E tests to handle new URL pattern Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com> --- actions/auth/register.ts | 2 +- app/account/page.tsx | 12 +- components/account/passkey-setup-prompt.tsx | 124 +++++++++++++++ components/auth/register-form.tsx | 2 +- e2e/admin/sign-in-permission.spec.ts | 2 +- e2e/admin/signup-settings.spec.ts | 8 +- e2e/auth/delete-account.spec.ts | 4 +- e2e/auth/login.spec.ts | 2 +- e2e/auth/passkey-setup.spec.ts | 159 ++++++++++++++++++++ e2e/auth/passkey.spec.ts | 4 +- e2e/auth/password-reset.spec.ts | 2 +- e2e/auth/register.spec.ts | 6 +- e2e/fixtures/test-helpers.ts | 4 +- e2e/oauth/consent.spec.ts | 6 +- e2e/oauth/public-client.spec.ts | 2 +- e2e/oauth/refresh-token.spec.ts | 2 +- e2e/oauth/user-id-consistency.spec.ts | 2 +- e2e/oauth/wildcard-redirect.spec.ts | 2 +- 18 files changed, 319 insertions(+), 26 deletions(-) create mode 100644 components/account/passkey-setup-prompt.tsx create mode 100644 e2e/auth/passkey-setup.spec.ts 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 14fb001..6d92374 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..00c6b89 --- /dev/null +++ b/components/account/passkey-setup-prompt.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState } 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 { 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 = () => { + setIsOpen(false); + clearError(); + router.replace("/account"); + }; + + 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 50e8af8..073fc6b 100644 --- a/components/auth/register-form.tsx +++ b/components/auth/register-form.tsx @@ -30,7 +30,7 @@ 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("/account"); + router.push("/account?setup=passkey"); } 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 647abc1..cabbb3f 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..ccbe417 --- /dev/null +++ b/e2e/auth/passkey-setup.spec.ts @@ -0,0 +1,159 @@ +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 }; +} + +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..48f7d00 100644 --- a/e2e/auth/passkey.spec.ts +++ b/e2e/auth/passkey.spec.ts @@ -33,7 +33,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 +115,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..f601636 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,7 +36,7 @@ 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"); + await expect(page).toHaveURL("/account?setup=passkey"); // Logout and wait for redirect to login await page.getByRole("button", { name: "Avatar" }).click(); diff --git a/e2e/fixtures/test-helpers.ts b/e2e/fixtures/test-helpers.ts index 0bec41c..ddea62e 100644 --- a/e2e/fixtures/test-helpers.ts +++ b/e2e/fixtures/test-helpers.ts @@ -15,8 +15,8 @@ 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 }); } export async function loginUser(page: Page, user = testUser) { diff --git a/e2e/oauth/consent.spec.ts b/e2e/oauth/consent.spec.ts index 20cfb66..bdc76de 100644 --- a/e2e/oauth/consent.spec.ts +++ b/e2e/oauth/consent.spec.ts @@ -24,7 +24,7 @@ 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"); + await expect(page).toHaveURL("/account?setup=passkey"); // Logout await page.getByRole("button", { name: "Avatar" }).click(); @@ -159,7 +159,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()}`; @@ -203,7 +203,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 0ad3020..a695a16 100644 --- a/e2e/oauth/public-client.spec.ts +++ b/e2e/oauth/public-client.spec.ts @@ -32,7 +32,7 @@ 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"); + await expect(page).toHaveURL("/account?setup=passkey"); // Logout await page.getByRole("button", { name: /avatar/i }).click(); diff --git a/e2e/oauth/refresh-token.spec.ts b/e2e/oauth/refresh-token.spec.ts index 0fdc37a..09147cd 100644 --- a/e2e/oauth/refresh-token.spec.ts +++ b/e2e/oauth/refresh-token.spec.ts @@ -33,7 +33,7 @@ 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"); + await expect(page).toHaveURL("/account?setup=passkey"); // Logout await page.getByRole("button", { name: "Avatar" }).click(); diff --git a/e2e/oauth/user-id-consistency.spec.ts b/e2e/oauth/user-id-consistency.spec.ts index b3bed68..21484ae 100644 --- a/e2e/oauth/user-id-consistency.spec.ts +++ b/e2e/oauth/user-id-consistency.spec.ts @@ -34,7 +34,7 @@ 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"); + await expect(page).toHaveURL("/account?setup=passkey"); // Logout await page.getByRole("button", { name: "Avatar" }).click(); diff --git a/e2e/oauth/wildcard-redirect.spec.ts b/e2e/oauth/wildcard-redirect.spec.ts index b69bf4b..a12547f 100644 --- a/e2e/oauth/wildcard-redirect.spec.ts +++ b/e2e/oauth/wildcard-redirect.spec.ts @@ -33,7 +33,7 @@ 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"); + await expect(page).toHaveURL("/account?setup=passkey"); // Logout await page.getByRole("button", { name: /avatar/i }).click(); From e2208ee35edc5e1f7fb9f40eba94dccfe34a3dc3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:30:16 +0000 Subject: [PATCH 3/7] improve accessibility: add ARIA attributes and Escape key support to passkey setup prompt Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com> --- components/account/passkey-setup-prompt.tsx | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/components/account/passkey-setup-prompt.tsx b/components/account/passkey-setup-prompt.tsx index 00c6b89..2285418 100644 --- a/components/account/passkey-setup-prompt.tsx +++ b/components/account/passkey-setup-prompt.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { motion, AnimatePresence } from "framer-motion"; import { Loader2, KeyRound, ShieldCheck } from "lucide-react"; @@ -27,11 +27,21 @@ export function PasskeySetupPrompt() { await registerPasskey(name.trim()); }; - const handleSkip = () => { + const handleSkip = useCallback(() => { setIsOpen(false); clearError(); router.replace("/account"); - }; + }, [clearError, router]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && isOpen && !isLoading) { + handleSkip(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, isLoading, handleSkip]); return ( @@ -48,6 +58,9 @@ export function PasskeySetupPrompt() { initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} + role="dialog" + aria-modal="true" + aria-labelledby="passkey-setup-title" className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-sm bg-background border rounded-lg p-6 z-50 shadow-lg" data-testid="passkey-setup-prompt" > @@ -56,7 +69,7 @@ export function PasskeySetupPrompt() {
-

Secure your account

+

Secure your account

Add a passkey for faster, more secure sign-in

From ffa336fa1e0d3d15b705d7048859953470dfab8c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:18:01 +0000 Subject: [PATCH 4/7] feat(profile): add avatar upload support using Vercel Blob with client-side upload and WebP conversion (#31) * Initial plan * feat: add avatar upload support using Vercel Blob - Add avatarUrl column to users table schema - Create database migration for avatarUrl column - Create uploadAvatar and removeAvatar server actions - Update profile form with avatar upload/remove UI - Update OAuth userinfo and token endpoints to use avatarUrl - Add avatar cleanup on user and account deletion - Update next.config.ts with Vercel Blob image domain - Add unit tests for avatar actions Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com> * refactor: extract avatar constants to shared module Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com> * feat: switch to client-side upload with WebP conversion via onUploadCompleted - Create /api/avatar/upload route with handleUpload for client-side uploads - Use onUploadCompleted callback to download, convert to WebP via sharp, and re-upload - Update profile form to use @vercel/blob/client upload() with polling for processed URL - Add sharp dependency for server-side image processing - Increase max file size to 4MB - Fix removeAvatar ordering: clear DB first, then delete blob (safe ordering) - Rename UploadAvatarResult to AvatarActionResult - Split success messages for profile vs avatar actions - Add getAvatarUrl server action for polling processed avatar URL - Update tests for new architecture (7 tests passing) Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com> * fix: address code review feedback - Convert handleRemoveAvatar to async function instead of IIFE - Move polling delay to end of loop for immediate first check - Remove unnecessary blob.url comparison in old avatar cleanup - Fix double type cast with spread operator for allowedContentTypes Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com> * fix: use uploaded avatar URL in header instead of always showing identicon Include avatarUrl in the account layout DB query and prefer it over the identicon fallback for the header avatar image. Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com> * fix: ensure header avatar displays as circle with object-cover and fixed dimensions Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com> --- actions/account/avatar.test.ts | 156 ++++ actions/account/avatar.ts | 87 ++ actions/account/delete-account.ts | 15 + actions/admin/users/delete.ts | 15 + app/account/layout.tsx | 5 +- app/account/page.tsx | 1 + app/api/avatar/upload/route.ts | 100 +++ app/api/oauth/token/route.ts | 2 +- app/api/oauth/userinfo/route.ts | 2 +- components/account/profile-form.tsx | 157 +++- lib/constants/avatar.ts | 9 + lib/db/migrations/0005_easy_avatar.sql | 1 + lib/db/migrations/meta/0005_snapshot.json | 944 ++++++++++++++++++++++ lib/db/migrations/meta/_journal.json | 7 + lib/db/schema/users.ts | 1 + next.config.ts | 9 +- package.json | 1 + 17 files changed, 1494 insertions(+), 18 deletions(-) create mode 100644 actions/account/avatar.test.ts create mode 100644 actions/account/avatar.ts create mode 100644 app/api/avatar/upload/route.ts create mode 100644 lib/constants/avatar.ts create mode 100644 lib/db/migrations/0005_easy_avatar.sql create mode 100644 lib/db/migrations/meta/0005_snapshot.json diff --git a/actions/account/avatar.test.ts b/actions/account/avatar.test.ts new file mode 100644 index 0000000..1f49b85 --- /dev/null +++ b/actions/account/avatar.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test"; + +// Mock user data +const mockUser = { + id: "user-123", + email: "test@example.com", + username: "testuser", + displayName: "Test User", + avatarSeed: "seed-123", + avatarUrl: null as string | null, + emailVerified: true, + passwordHash: "hash", + createdAt: new Date(), + updatedAt: new Date(), +}; + +// Mock the database module +const mockFindFirst = mock(() => Promise.resolve(mockUser)); +const mockUpdate = mock(() => ({ + set: mock(() => ({ + where: mock(() => Promise.resolve()), + })), +})); +const mockDb = { + query: { + users: { + findFirst: mockFindFirst, + }, + }, + update: mockUpdate, +}; + +// Mock drizzle-orm eq function +mock.module("drizzle-orm", () => ({ + eq: (field: unknown, value: unknown) => ({ field, value }), +})); + +// Mock the db module +mock.module("@/lib/db", () => ({ + db: mockDb, +})); + +// Mock the schema module +mock.module("@/lib/db/schema", () => ({ + users: { id: "id", avatarUrl: "avatar_url", updatedAt: "updated_at" }, +})); + +// Mock requireAuth to always succeed +mock.module("@/lib/auth/session", () => ({ + requireAuth: mock(() => Promise.resolve({ userId: "user-123" })), +})); + +// Mock blob functions +const mockDeleteImage = mock(() => Promise.resolve()); +mock.module("@/lib/blob", () => ({ + deleteImage: mockDeleteImage, +})); + +// Import after mocking +const { removeAvatar, getAvatarUrl } = await import("./avatar"); + +describe("removeAvatar", () => { + beforeEach(() => { + mockDb.query.users.findFirst = mock(() => + Promise.resolve({ ...mockUser, avatarUrl: "https://blob.vercel-storage.com/avatars/avatar.webp" }) + ); + mockDb.update = mock(() => ({ + set: mock(() => ({ + where: mock(() => Promise.resolve()), + })), + })); + mockDeleteImage.mockImplementation(() => Promise.resolve()); + }); + + test("should remove avatar successfully", async () => { + const result = await removeAvatar(); + + expect(result.success).toBe(true); + expect(mockDeleteImage).toHaveBeenCalled(); + }); + + test("should clear DB before deleting blob (safe ordering)", async () => { + const callOrder: string[] = []; + mockDb.update = mock(() => { + callOrder.push("db_update"); + return { + set: mock(() => ({ + where: mock(() => Promise.resolve()), + })), + }; + }); + mockDeleteImage.mockImplementation(() => { + callOrder.push("blob_delete"); + return Promise.resolve(); + }); + + await removeAvatar(); + + expect(callOrder[0]).toBe("db_update"); + expect(callOrder[1]).toBe("blob_delete"); + }); + + test("should succeed even when user has no avatar", async () => { + mockDb.query.users.findFirst = mock(() => + Promise.resolve({ ...mockUser, avatarUrl: null }) + ); + + const result = await removeAvatar(); + + expect(result.success).toBe(true); + }); + + test("should return error when user not found", async () => { + mockDb.query.users.findFirst = mock(() => Promise.resolve(null)); + + const result = await removeAvatar(); + + expect(result.success).toBe(false); + expect(result.error).toBe("User not found"); + }); +}); + +describe("getAvatarUrl", () => { + beforeEach(() => { + mockDb.query.users.findFirst = mock(() => + Promise.resolve({ ...mockUser, avatarUrl: "https://blob.vercel-storage.com/avatars/avatar.webp" }) + ); + }); + + test("should return avatar URL when set", async () => { + const result = await getAvatarUrl(); + + expect(result.success).toBe(true); + expect(result.avatarUrl).toBe("https://blob.vercel-storage.com/avatars/avatar.webp"); + }); + + test("should return null avatar URL when not set", async () => { + mockDb.query.users.findFirst = mock(() => + Promise.resolve({ ...mockUser, avatarUrl: null }) + ); + + const result = await getAvatarUrl(); + + expect(result.success).toBe(true); + expect(result.avatarUrl).toBeNull(); + }); + + test("should return error when user not found", async () => { + mockDb.query.users.findFirst = mock(() => Promise.resolve(null)); + + const result = await getAvatarUrl(); + + expect(result.success).toBe(false); + expect(result.error).toBe("User not found"); + }); +}); diff --git a/actions/account/avatar.ts b/actions/account/avatar.ts new file mode 100644 index 0000000..6fb265b --- /dev/null +++ b/actions/account/avatar.ts @@ -0,0 +1,87 @@ +"use server"; + +import { db } from "@/lib/db"; +import { users } from "@/lib/db/schema"; +import { requireAuth } from "@/lib/auth/session"; +import { deleteImage } from "@/lib/blob"; +import { eq } from "drizzle-orm"; + +export interface AvatarActionResult { + success: boolean; + error?: string; + avatarUrl?: string | null; +} + +export async function removeAvatar(): Promise { + try { + const session = await requireAuth(); + + // Get current user + const user = await db.query.users.findFirst({ + where: eq(users.id, session.userId!), + }); + + if (!user) { + return { + success: false, + error: "User not found", + }; + } + + // Clear avatar URL from user record first + const oldAvatarUrl = user.avatarUrl; + await db + .update(users) + .set({ + avatarUrl: null, + updatedAt: new Date(), + }) + .where(eq(users.id, session.userId!)); + + // Delete avatar from blob storage if exists (best-effort cleanup) + if (oldAvatarUrl) { + try { + await deleteImage(oldAvatarUrl); + } catch { + // Ignore deletion errors + } + } + + return { success: true }; + } catch (error) { + console.error("Remove avatar error:", error); + return { + success: false, + error: "Failed to remove avatar", + }; + } +} + +/** + * Get the current user's avatar URL. + * Used to poll for the processed WebP avatar after client upload. + */ +export async function getAvatarUrl(): Promise { + try { + const session = await requireAuth(); + + const user = await db.query.users.findFirst({ + where: eq(users.id, session.userId!), + }); + + if (!user) { + return { + success: false, + error: "User not found", + }; + } + + return { success: true, avatarUrl: user.avatarUrl }; + } catch (error) { + console.error("Get avatar URL error:", error); + return { + success: false, + error: "Failed to get avatar URL", + }; + } +} diff --git a/actions/account/delete-account.ts b/actions/account/delete-account.ts index 7201289..306743f 100644 --- a/actions/account/delete-account.ts +++ b/actions/account/delete-account.ts @@ -3,6 +3,7 @@ import { db } from "@/lib/db"; import { users } from "@/lib/db/schema"; import { requireAuth, destroySession } from "@/lib/auth/session"; +import { deleteImage } from "@/lib/blob"; import { eq } from "drizzle-orm"; export interface DeleteAccountResult { @@ -14,6 +15,20 @@ export async function deleteAccount(): Promise { try { const session = await requireAuth(); + // Get user to check for avatar + const user = await db.query.users.findFirst({ + where: eq(users.id, session.userId!), + }); + + // Delete avatar from blob storage if exists + if (user?.avatarUrl) { + try { + await deleteImage(user.avatarUrl); + } catch { + // Ignore deletion errors for blob cleanup + } + } + await db.delete(users).where(eq(users.id, session.userId!)); await destroySession(); diff --git a/actions/admin/users/delete.ts b/actions/admin/users/delete.ts index a39b7ec..5ad4023 100644 --- a/actions/admin/users/delete.ts +++ b/actions/admin/users/delete.ts @@ -3,6 +3,7 @@ import { db } from "@/lib/db"; import { users } from "@/lib/db/schema"; import { requireAdmin } from "@/lib/auth/session"; +import { deleteImage } from "@/lib/blob"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; @@ -22,6 +23,20 @@ export async function deleteUser(userId: string): Promise { }; } + // Get user to check for avatar + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + + // Delete avatar from blob storage if exists + if (user?.avatarUrl) { + try { + await deleteImage(user.avatarUrl); + } catch { + // Ignore deletion errors for blob cleanup + } + } + await db.delete(users).where(eq(users.id, userId)); revalidatePath("/admin/dashboard/users"); diff --git a/app/account/layout.tsx b/app/account/layout.tsx index e3a1779..3923584 100644 --- a/app/account/layout.tsx +++ b/app/account/layout.tsx @@ -43,6 +43,7 @@ export default async function AccountLayout({ username: true, displayName: true, avatarSeed: true, + avatarUrl: true, emailVerified: true, }, }); @@ -57,11 +58,11 @@ export default async function AccountLayout({ Avatar diff --git a/app/account/page.tsx b/app/account/page.tsx index 6d92374..49769ad 100644 --- a/app/account/page.tsx +++ b/app/account/page.tsx @@ -50,6 +50,7 @@ export default async function AccountPage({ username: user.username, displayName: user.displayName, avatarSeed: user.avatarSeed, + avatarUrl: user.avatarUrl, }} /> diff --git a/app/api/avatar/upload/route.ts b/app/api/avatar/upload/route.ts new file mode 100644 index 0000000..a2ac8af --- /dev/null +++ b/app/api/avatar/upload/route.ts @@ -0,0 +1,100 @@ +import { handleUpload, type HandleUploadBody } from "@vercel/blob/client"; +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/auth/session"; +import { db } from "@/lib/db"; +import { users } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; +import { put, del } from "@vercel/blob"; +import sharp from "sharp"; +import { + AVATAR_MAX_FILE_SIZE, + AVATAR_ALLOWED_TYPES, +} from "@/lib/constants/avatar"; + +export async function POST(request: NextRequest) { + const body = (await request.json()) as HandleUploadBody; + + try { + const jsonResponse = await handleUpload({ + body, + request, + onBeforeGenerateToken: async (_pathname, clientPayload) => { + // Authenticate user + const session = await getSession(); + if (!session.isLoggedIn || !session.userId) { + throw new Error("Not authenticated"); + } + + return { + allowedContentTypes: [...AVATAR_ALLOWED_TYPES], + maximumSizeInBytes: AVATAR_MAX_FILE_SIZE, + tokenPayload: JSON.stringify({ + userId: session.userId, + ...(clientPayload ? { clientPayload } : {}), + }), + }; + }, + onUploadCompleted: async ({ blob, tokenPayload }) => { + try { + const payload = JSON.parse(tokenPayload as string); + const userId = payload.userId as string; + + // Download the uploaded image + const response = await fetch(blob.url); + const imageBuffer = Buffer.from(await response.arrayBuffer()); + + // Convert to WebP using sharp + const webpBuffer = await sharp(imageBuffer) + .webp({ quality: 80 }) + .toBuffer(); + + // Upload the WebP version + const webpFilename = `avatars/${crypto.randomUUID()}.webp`; + const webpBlob = await put(webpFilename, webpBuffer, { + access: "public", + contentType: "image/webp", + }); + + // Delete the original uploaded file + try { + await del(blob.url); + } catch { + // Ignore deletion errors for original file + } + + // Get user's current avatar to clean up + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + + // Delete old avatar if exists + if (user?.avatarUrl) { + try { + await del(user.avatarUrl); + } catch { + // Ignore deletion errors for old avatar + } + } + + // Update user record with the WebP URL + await db + .update(users) + .set({ + avatarUrl: webpBlob.url, + updatedAt: new Date(), + }) + .where(eq(users.id, userId)); + } catch (error) { + console.error("onUploadCompleted error:", error); + throw error; + } + }, + }); + + return NextResponse.json(jsonResponse); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error occurred"; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/app/api/oauth/token/route.ts b/app/api/oauth/token/route.ts index b3b8f62..3b5926e 100644 --- a/app/api/oauth/token/route.ts +++ b/app/api/oauth/token/route.ts @@ -261,7 +261,7 @@ async function handleAuthorizationCodeGrant( // Always include profile claims name: user.displayName ?? undefined, preferred_username: user.username ?? undefined, - picture: `${process.env.OAUTH_ISSUER_URL}/api/avatar/${user.avatarSeed || user.id}`, + picture: user.avatarUrl || `${process.env.OAUTH_ISSUER_URL}/api/avatar/${user.avatarSeed || user.id}`, nonce: codeData.nonce, auth_time: Math.floor(Date.now() / 1000), }, diff --git a/app/api/oauth/userinfo/route.ts b/app/api/oauth/userinfo/route.ts index 2b25b2e..113bbb4 100644 --- a/app/api/oauth/userinfo/route.ts +++ b/app/api/oauth/userinfo/route.ts @@ -55,7 +55,7 @@ async function handleUserInfo(request: NextRequest) { // Always include profile claims name: user.displayName, preferred_username: user.username, - picture: `${process.env.OAUTH_ISSUER_URL}/api/avatar/${user.avatarSeed || user.id}`, + picture: user.avatarUrl || `${process.env.OAUTH_ISSUER_URL}/api/avatar/${user.avatarSeed || user.id}`, }; if (grantedScopes.includes("email")) { diff --git a/components/account/profile-form.tsx b/components/account/profile-form.tsx index 494423a..6912050 100644 --- a/components/account/profile-form.tsx +++ b/components/account/profile-form.tsx @@ -1,12 +1,15 @@ "use client"; -import { useState, useTransition } from "react"; +import { useState, useRef, useTransition, useCallback } from "react"; +import { upload } from "@vercel/blob/client"; import { motion } from "framer-motion"; -import { Loader2, Save } from "lucide-react"; +import { Loader2, Save, Upload, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { updateProfile } from "@/actions/account/update-profile"; +import { removeAvatar, getAvatarUrl } from "@/actions/account/avatar"; +import { AVATAR_ACCEPT, AVATAR_MAX_FILE_SIZE } from "@/lib/constants/avatar"; interface ProfileFormProps { user: { @@ -14,20 +17,29 @@ interface ProfileFormProps { username: string | null; displayName: string | null; avatarSeed: string | null; + avatarUrl: string | null; }; } export function ProfileForm({ user }: ProfileFormProps) { const [username, setUsername] = useState(user.username || ""); const [displayName, setDisplayName] = useState(user.displayName || ""); + const [currentAvatarUrl, setCurrentAvatarUrl] = useState(user.avatarUrl); const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); const [isPending, startTransition] = useTransition(); + const [isAvatarPending, setIsAvatarPending] = useState(false); + const fileInputRef = useRef(null); + + const showSuccess = useCallback((message: string) => { + setSuccessMessage(message); + setTimeout(() => setSuccessMessage(null), 3000); + }, []); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setError(null); - setSuccess(false); + setSuccessMessage(null); startTransition(async () => { const result = await updateProfile({ @@ -36,14 +48,98 @@ export function ProfileForm({ user }: ProfileFormProps) { }); if (result.success) { - setSuccess(true); - setTimeout(() => setSuccess(false), 3000); + showSuccess("Profile updated successfully!"); } else { setError(result.error || "Failed to update profile"); } }); }; + const handleAvatarUpload = async ( + e: React.ChangeEvent + ) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Client-side validation + if (file.size > AVATAR_MAX_FILE_SIZE) { + setError("File is too large. Maximum size is 4MB."); + return; + } + + setError(null); + setSuccessMessage(null); + setIsAvatarPending(true); + + try { + // Upload using Vercel Blob client upload + await upload(file.name, file, { + access: "public", + handleUploadUrl: "/api/avatar/upload", + }); + + // Poll for the processed WebP avatar URL + // The onUploadCompleted callback processes the image server-side + let attempts = 0; + const maxAttempts = 10; + const pollInterval = 1500; + + const pollForAvatar = async (): Promise => { + while (attempts < maxAttempts) { + attempts++; + const result = await getAvatarUrl(); + if (result.success && result.avatarUrl && result.avatarUrl !== currentAvatarUrl) { + return result.avatarUrl; + } + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + return null; + }; + + const newAvatarUrl = await pollForAvatar(); + if (newAvatarUrl) { + setCurrentAvatarUrl(newAvatarUrl); + showSuccess("Avatar uploaded successfully!"); + } else { + // Even if polling doesn't find the update, the upload succeeded + // Just reload to get the latest state + showSuccess("Avatar uploaded! It may take a moment to process."); + } + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to upload avatar" + ); + } finally { + setIsAvatarPending(false); + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }; + + const handleRemoveAvatar = async () => { + setError(null); + setSuccessMessage(null); + setIsAvatarPending(true); + + try { + const result = await removeAvatar(); + + if (result.success) { + setCurrentAvatarUrl(null); + showSuccess("Avatar removed successfully!"); + } else { + setError(result.error || "Failed to remove avatar"); + } + } finally { + setIsAvatarPending(false); + } + }; + + const avatarSrc = + currentAvatarUrl || `/api/avatar/${user.avatarSeed || user.email}`; + return (
{error && ( @@ -56,26 +152,61 @@ export function ProfileForm({ user }: ProfileFormProps) { )} - {success && ( + {successMessage && ( - Profile updated successfully! + {successMessage} )}
Avatar -
-

- Your avatar is automatically generated +

+
+ + {currentAvatarUrl && ( + + )} +
+

+ JPEG, PNG, WebP, or GIF. Max 4MB.

+
diff --git a/lib/constants/avatar.ts b/lib/constants/avatar.ts new file mode 100644 index 0000000..775eb78 --- /dev/null +++ b/lib/constants/avatar.ts @@ -0,0 +1,9 @@ +export const AVATAR_MAX_FILE_SIZE = 4 * 1024 * 1024; // 4MB +export const AVATAR_ALLOWED_TYPES = [ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", +] as const; + +export const AVATAR_ACCEPT = AVATAR_ALLOWED_TYPES.join(","); diff --git a/lib/db/migrations/0005_easy_avatar.sql b/lib/db/migrations/0005_easy_avatar.sql new file mode 100644 index 0000000..2222b9e --- /dev/null +++ b/lib/db/migrations/0005_easy_avatar.sql @@ -0,0 +1 @@ +ALTER TABLE `users` ADD `avatar_url` text; \ No newline at end of file diff --git a/lib/db/migrations/meta/0005_snapshot.json b/lib/db/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..c9a3963 --- /dev/null +++ b/lib/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,944 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "unique_0005_easy_avatar", + "prevId": "unique_0004_dark_sentinel", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + "username" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkeys": { + "name": "passkeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkeys_user_idx": { + "name": "passkeys_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkeys_user_id_users_id_fk": { + "name": "passkeys_user_id_users_id_fk", + "tableFrom": "passkeys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_verification_tokens": { + "name": "email_verification_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "email_verification_tokens_token_unique": { + "name": "email_verification_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "email_verification_tokens_user_idx": { + "name": "email_verification_tokens_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "email_verification_tokens_token_idx": { + "name": "email_verification_tokens_token_idx", + "columns": [ + "token" + ], + "isUnique": false + } + }, + "foreignKeys": { + "email_verification_tokens_user_id_users_id_fk": { + "name": "email_verification_tokens_user_id_users_id_fk", + "tableFrom": "email_verification_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "password_reset_tokens": { + "name": "password_reset_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "password_reset_tokens_token_unique": { + "name": "password_reset_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "password_reset_tokens_user_idx": { + "name": "password_reset_tokens_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "password_reset_tokens_token_idx": { + "name": "password_reset_tokens_token_idx", + "columns": [ + "token" + ], + "isUnique": false + } + }, + "foreignKeys": { + "password_reset_tokens_user_id_users_id_fk": { + "name": "password_reset_tokens_user_id_users_id_fk", + "tableFrom": "password_reset_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_clients": { + "name": "oauth_clients", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_type": { + "name": "client_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'confidential'" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allowed_scopes": { + "name": "allowed_scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_first_party": { + "name": "is_first_party", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sign_in_permission": { + "name": "sign_in_permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + } + }, + "indexes": { + "oauth_clients_name_idx": { + "name": "oauth_clients_name_idx", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_consents": { + "name": "oauth_consents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_consents_user_client_idx": { + "name": "oauth_consents_user_client_idx", + "columns": [ + "user_id", + "client_id" + ], + "isUnique": true + }, + "oauth_consents_user_idx": { + "name": "oauth_consents_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "oauth_consents_client_idx": { + "name": "oauth_consents_client_idx", + "columns": [ + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_client_id_oauth_clients_id_fk": { + "name": "oauth_consents_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "oauth_refresh_tokens_token_unique": { + "name": "oauth_refresh_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "oauth_refresh_tokens_user_idx": { + "name": "oauth_refresh_tokens_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "oauth_refresh_tokens_token_idx": { + "name": "oauth_refresh_tokens_token_idx", + "columns": [ + "token" + ], + "isUnique": false + }, + "oauth_refresh_tokens_client_idx": { + "name": "oauth_refresh_tokens_client_idx", + "columns": [ + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_client_id_oauth_clients_id_fk": { + "name": "oauth_refresh_tokens_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_passkeys": { + "name": "admin_passkeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sign_up_enabled": { + "name": "sign_up_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sign_up_whitelist_enabled": { + "name": "sign_up_whitelist_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_whitelist": { + "name": "email_whitelist", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "email_whitelist_email_idx": { + "name": "email_whitelist_email_idx", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client_email_whitelist": { + "name": "oauth_client_email_whitelist", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_client_email_whitelist_client_email_idx": { + "name": "oauth_client_email_whitelist_client_email_idx", + "columns": [ + "client_id", + "email" + ], + "isUnique": true + }, + "oauth_client_email_whitelist_client_idx": { + "name": "oauth_client_email_whitelist_client_idx", + "columns": [ + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_client_email_whitelist_client_id_oauth_clients_id_fk": { + "name": "oauth_client_email_whitelist_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_client_email_whitelist", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index deaccd0..cefbf6b 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1769900000000, "tag": "0004_dark_sentinel", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1772582400000, + "tag": "0005_easy_avatar", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema/users.ts b/lib/db/schema/users.ts index 79650dd..5d4f9cc 100644 --- a/lib/db/schema/users.ts +++ b/lib/db/schema/users.ts @@ -10,6 +10,7 @@ export const users = sqliteTable( username: text("username").unique(), displayName: text("display_name"), avatarSeed: text("avatar_seed"), // for geometric identicon generation + avatarUrl: text("avatar_url"), // uploaded avatar URL (Vercel Blob) createdAt: integer("created_at", { mode: "timestamp" }).notNull(), updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), }, diff --git a/next.config.ts b/next.config.ts index e9ffa30..8e244ef 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,14 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "*.public.blob.vercel-storage.com", + }, + ], + }, }; export default nextConfig; diff --git a/package.json b/package.json index ad05321..a9ecf91 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "react-dom": "19.2.3", "resend": "^6.7.0", "shadcn": "^3.6.3", + "sharp": "^0.34.5", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0", "zod": "^4.3.5" From b11e7c20ef7b6a45f308499248f57e309544a4e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:16:43 +0000 Subject: [PATCH 5/7] Initial plan From 58c3b48e40f4ed9d53eaccf6c8d4ebccb512171e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 04:00:21 +0000 Subject: [PATCH 6/7] fix: dismiss passkey prompt before interacting with UI in E2E tests - Add prompt dismissal (skip) after registration in all E2E tests that need to interact with elements behind the modal overlay - Extract setupWebAuthn helper to shared e2e/fixtures/webauthn.ts - Fix registerUser helper to assert exact URL and dismiss prompt - Guard backdrop click during loading state - Add focus management (auto-focus passkey name input on dialog open) Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com> --- components/account/passkey-setup-prompt.tsx | 14 ++++++++++++-- e2e/auth/passkey-setup.spec.ts | 21 ++------------------- e2e/auth/passkey.spec.ts | 19 ++----------------- e2e/auth/register.spec.ts | 4 ++++ e2e/fixtures/test-helpers.ts | 7 ++++++- e2e/fixtures/webauthn.ts | 19 +++++++++++++++++++ e2e/oauth/consent.spec.ts | 4 ++++ e2e/oauth/public-client.spec.ts | 4 ++++ e2e/oauth/refresh-token.spec.ts | 4 ++++ e2e/oauth/user-id-consistency.spec.ts | 4 ++++ e2e/oauth/wildcard-redirect.spec.ts | 4 ++++ 11 files changed, 65 insertions(+), 39 deletions(-) create mode 100644 e2e/fixtures/webauthn.ts diff --git a/components/account/passkey-setup-prompt.tsx b/components/account/passkey-setup-prompt.tsx index 2285418..07f5099 100644 --- a/components/account/passkey-setup-prompt.tsx +++ b/components/account/passkey-setup-prompt.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +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"; @@ -13,6 +13,7 @@ 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: () => { @@ -28,10 +29,11 @@ export function PasskeySetupPrompt() { }; const handleSkip = useCallback(() => { + if (isLoading) return; setIsOpen(false); clearError(); router.replace("/account"); - }, [clearError, router]); + }, [clearError, router, isLoading]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -43,6 +45,13 @@ export function PasskeySetupPrompt() { 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 && ( @@ -86,6 +95,7 @@ export function PasskeySetupPrompt() {
{ - 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 Setup Prompt After Signup", () => { test("should show passkey setup prompt after registration", async ( diff --git a/e2e/auth/passkey.spec.ts b/e2e/auth/passkey.spec.ts index 48f7d00..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 }; diff --git a/e2e/auth/register.spec.ts b/e2e/auth/register.spec.ts index f601636..7d42155 100644 --- a/e2e/auth/register.spec.ts +++ b/e2e/auth/register.spec.ts @@ -38,6 +38,10 @@ test.describe("User Registration", () => { 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 await page.getByRole("button", { name: "Avatar" }).click(); await page.getByRole("button", { name: /sign out/i }).click(); diff --git a/e2e/fixtures/test-helpers.ts b/e2e/fixtures/test-helpers.ts index ddea62e..df24e93 100644 --- a/e2e/fixtures/test-helpers.ts +++ b/e2e/fixtures/test-helpers.ts @@ -16,7 +16,12 @@ export async function registerUser(page: Page, user = testUser) { await page.getByRole("button", { name: "Create account" }).click(); // Should redirect to account page with passkey setup prompt (email verification skipped in E2E) - await expect(page).toHaveURL(/\/account(\?setup=passkey)?/, { timeout: 20000 }); + 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/consent.spec.ts b/e2e/oauth/consent.spec.ts index bdc76de..f334646 100644 --- a/e2e/oauth/consent.spec.ts +++ b/e2e/oauth/consent.spec.ts @@ -26,6 +26,10 @@ test.describe("OAuth Consent Flow", () => { 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 await page.getByRole("button", { name: "Avatar" }).click(); await page.getByRole("button", { name: /sign out/i }).click(); diff --git a/e2e/oauth/public-client.spec.ts b/e2e/oauth/public-client.spec.ts index a695a16..6e91890 100644 --- a/e2e/oauth/public-client.spec.ts +++ b/e2e/oauth/public-client.spec.ts @@ -34,6 +34,10 @@ test.describe("OAuth Public Client Flow", () => { 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 await page.getByRole("button", { name: /avatar/i }).click(); await page.getByRole("menuitem", { name: /sign out/i }).click(); diff --git a/e2e/oauth/refresh-token.spec.ts b/e2e/oauth/refresh-token.spec.ts index 09147cd..bf52fa3 100644 --- a/e2e/oauth/refresh-token.spec.ts +++ b/e2e/oauth/refresh-token.spec.ts @@ -35,6 +35,10 @@ test.describe("OAuth Refresh Token Flow", () => { 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 await page.getByRole("button", { name: "Avatar" }).click(); await page.getByRole("button", { name: /sign out/i }).click(); diff --git a/e2e/oauth/user-id-consistency.spec.ts b/e2e/oauth/user-id-consistency.spec.ts index 21484ae..41f30e0 100644 --- a/e2e/oauth/user-id-consistency.spec.ts +++ b/e2e/oauth/user-id-consistency.spec.ts @@ -36,6 +36,10 @@ test.describe("User ID Consistency", () => { 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 await page.getByRole("button", { name: "Avatar" }).click(); await page.getByRole("button", { name: /sign out/i }).click(); diff --git a/e2e/oauth/wildcard-redirect.spec.ts b/e2e/oauth/wildcard-redirect.spec.ts index a12547f..4d22869 100644 --- a/e2e/oauth/wildcard-redirect.spec.ts +++ b/e2e/oauth/wildcard-redirect.spec.ts @@ -35,6 +35,10 @@ test.describe("OAuth Wildcard Redirect URI", () => { 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 await page.getByRole("button", { name: /avatar/i }).click(); await page.getByRole("menuitem", { name: /sign out/i }).click(); From b86ec10aca642c7dfc2e459146c1165e3b6ce246 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 26 May 2026 22:50:43 +0800 Subject: [PATCH 7/7] test: dismiss passkey setup prompt in account-select e2e Registration now redirects to /account?setup=passkey, so the three account-select specs that registered a fresh user need to skip the prompt before continuing the OAuth flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/oauth/account-select.spec.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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