Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion actions/auth/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export async function registerAndRedirect(input: RegisterInput): Promise<void> {

if (result.success) {
if (process.env.E2E_SKIP_EMAIL_VERIFICATION === "true") {
redirect("/account");
redirect("/account?setup=passkey");
} else {
redirect("/verify-email?sent=true");
}
Expand Down
12 changes: 11 additions & 1 deletion app/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) {
Expand All @@ -27,6 +32,9 @@ export default async function AccountPage() {
redirect("/login");
}

const params = await searchParams;
const showPasskeySetup = params.setup === "passkey";
Comment on lines +35 to +36

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says the passkey prompt should appear after first-time signup, but AccountPage only triggers the prompt when ?setup=passkey is present. In the normal (non-E2E) signup flow, email verification redirects to /account?verified=true (see actions/auth/verify-email.ts), so new users won’t see this prompt unless that redirect (or this condition) is updated accordingly.

Copilot uses AI. Check for mistakes.

return (
<div className="space-y-6">
<PageHeader
Expand All @@ -48,6 +56,8 @@ export default async function AccountPage() {
/>
</CardContent>
</Card>

{showPasskeySetup && <PasskeySetupPrompt />}
</div>
);
}
147 changes: 147 additions & 0 deletions components/account/passkey-setup-prompt.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(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 (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-40"
onClick={handleSkip}
/>
Comment on lines +59 to +65

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backdrop clicks currently call handleSkip even while a passkey registration is in-flight (isLoading), which can close/unmount the prompt mid-request and trigger state updates on an unmounted component (and potentially confuse users). Consider disabling the backdrop click while isLoading (similar to the Escape-key guard) or guarding handleSkip early-return when loading.

Copilot uses AI. Check for mistakes.
<motion.div
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"
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<ShieldCheck className="w-5 h-5 text-primary" />
</div>
<div>
<h2 id="passkey-setup-title" className="font-semibold">Secure your account</h2>
<p className="text-sm text-muted-foreground">
Add a passkey for faster, more secure sign-in
</p>
</div>
</div>

{error && (
<div className="bg-destructive/10 border border-destructive/20 text-destructive text-sm rounded-lg p-3 mb-4">
{error}
</div>
)}

<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="setup-passkey-name">Passkey Name</Label>
<Input
ref={inputRef}
id="setup-passkey-name"
placeholder="e.g., MacBook Pro, iPhone"
value={name}
onChange={(e) => setName(e.target.value)}
Comment on lines +96 to +102

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this dialog auto-opens, it should manage keyboard focus (e.g., set initial focus to the passkey name input and trap focus within the dialog) so keyboard/screen-reader users don’t remain focused behind the modal.

Copilot uses AI. Check for mistakes.
disabled={isLoading}
data-testid="setup-passkey-name"
/>
<p className="text-xs text-muted-foreground">
Give your passkey a name to identify it later
</p>
</div>

<div className="flex gap-3">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={handleSkip}
disabled={isLoading}
data-testid="skip-passkey-setup"
>
Skip for now
</Button>
<Button
type="submit"
className="flex-1"
disabled={isLoading || !name.trim()}
data-testid="register-passkey-setup"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Registering...
</>
) : (
<>
<KeyRound className="w-4 h-4" />
Add Passkey
</>
)}
</Button>
</div>
</form>
</motion.div>
</>
)}
</AnimatePresence>
);
}
3 changes: 2 additions & 1 deletion components/auth/register-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
2 changes: 1 addition & 1 deletion e2e/admin/sign-in-permission.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 4 additions & 4 deletions e2e/admin/signup-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand Down Expand Up @@ -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 ({
Expand Down
4 changes: 2 additions & 2 deletions e2e/auth/delete-account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion e2e/auth/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
Loading