diff --git a/.gitignore b/.gitignore index 5565c87..fd25936 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ yarn-error.log* # local visual verification screenshots /.verify /.observability-screens +/.playwright-screenshots # ad-hoc screenshots/logs dropped at repo root during manual testing /*.png /*.jpg diff --git a/app/login/verified/page.tsx b/app/login/verified/page.tsx new file mode 100644 index 0000000..ba8d345 --- /dev/null +++ b/app/login/verified/page.tsx @@ -0,0 +1,22 @@ +import { redirect } from "next/navigation"; + +import { getSessionFromHeaders } from "@/lib/auth/queries"; + +import { VerifiedView } from "./verified-view"; + +// Better Auth's email verification lands here via callbackURL=/login/verified +// (rewritten from the default "/" in lib/auth/config.ts). The token is +// consumed server-side by Better Auth and never forwarded in the 302, so +// `searchParams` has no verification signal we can trust — the only reliable +// signal is `session` (autoSignInAfterVerification is on, see +// lib/auth/config.ts). Anyone landing here without a session is a random +// visitor / bookmark, not a verified user, and gets redirected to /login. +export default async function VerifiedPage() { + const session = await getSessionFromHeaders(); + + if (!session) { + redirect("/login"); + } + + return ; +} diff --git a/app/login/verified/verified-view.tsx b/app/login/verified/verified-view.tsx new file mode 100644 index 0000000..f6e7255 --- /dev/null +++ b/app/login/verified/verified-view.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { CircleCheckIcon, MessagesSquareIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { FieldDescription } from "@/components/ui/field"; + +const REDIRECT_SECONDS = 5; + +// The server page at app/login/verified/page.tsx redirects to /login +// whenever there's no session, so by the time this component renders the +// user is verified and signed in. No `hasSession` prop needed — the page +// is the gate. +export function VerifiedView() { + const router = useRouter(); + const [remaining, setRemaining] = useState(REDIRECT_SECONDS); + + useEffect(() => { + if (remaining <= 0) { + router.replace("/chat"); + return; + } + const t = setTimeout(() => setRemaining((s) => s - 1), 1000); + return () => clearTimeout(t); + }, [remaining, router]); + + return ( +
+ + + + Email verified + + + +
+ + Your email is confirmed. You're signed in. + + + + {remaining > 0 ? `Redirecting in ${remaining}s…` : "Redirecting…"} + + + +
+
+
+
+ ); +} diff --git a/docs/AUTH.md b/docs/AUTH.md index 11148f8..743567f 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -4,12 +4,13 @@ The chat app is gated behind a user account. Registration and sign-in go through ## Routes -| Path | What it does | -| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/login` | Auth UI (sign-in by default; sub-paths `/login/sign-up`, `/login/forgot-password` render the corresponding views). Rendered by `app/login/[[...path]]` from `@daveyplate/better-auth-ui`. | -| `/api/auth/verify-email?token=...` | The verification email links here — Better Auth consumes the token at the API level and 302s to the configured `callbackURL` (default `/`). | -| `/chat` | The actual chat UI. Server component checks the session and redirects unauthenticated requests to `/login`. | -| `/api/auth/*` | Better Auth's catch-all — see `docs/APIS.md` for the endpoint list. | +| Path | What it does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/login` | Auth UI (sign-in by default; sub-paths `/login/sign-up`, `/login/forgot-password`, `/login/verify-email` render the corresponding views). Rendered by `app/login/[[...path]]`. | +| `/login/verified` | Post-verification success page. Better Auth consumes the token at `/api/auth/verify-email` and 302s here (the default `callbackURL="/"` is rewritten to `/login/verified` in `lib/auth/config.ts`). `autoSignInAfterVerification: true` is on — a verified user lands here with a real session and the CTA goes straight to `/chat`. Direct visits without a session redirect to `/login` immediately. | +| `/api/auth/verify-email?token=...` | The verification email links here — Better Auth consumes the token at the API level and 302s to the configured `callbackURL` (rewritten to `/login/verified`). | +| `/chat` | The actual chat UI. Server component checks the session and redirects unauthenticated requests to `/login`. | +| `/api/auth/*` | Better Auth's catch-all — see `docs/APIS.md` for the endpoint list. | ## Local dev setup diff --git a/lib/auth/config.ts b/lib/auth/config.ts index f580a00..dd30ee0 100644 --- a/lib/auth/config.ts +++ b/lib/auth/config.ts @@ -69,8 +69,20 @@ export const auth = }, }, emailVerification: { + // ponytail: Better Auth 1.6.x defaults this to falsy — verified users + // land on /login/verified without a session cookie AND without a token + // query param (Better Auth's 302 carries just the callbackURL value). + // Our success page then has no signal to render against and the + // !token && !session fallback bounces them back to /login. Flip this + // on so the verified user gets a real session and the success page is + // reachable. Email link is the auth token either way, so this doesn't + // widen the threat model meaningfully. + autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, url }) => { - const result = await sendVerificationEmail({ to: user.email, url }); + const result = await sendVerificationEmail({ + to: user.email, + url: verificationRedirectUrl(url), + }); if (!result.ok) { // Translate our internal codes to the Better Auth error shape so // the route can return the FR-025 EMAIL_QUOTA_EXCEEDED contract. @@ -87,3 +99,26 @@ export const auth = if (process.env.NODE_ENV !== "production") globalThis.__auth = auth; export type Session = typeof auth.$Infer.Session; + +/** + * Re-point Better Auth's verification-link `callbackURL` at our success page. + * + * Better Auth constructs the verification link as + * `${baseURL}/api/auth/verify-email?token=...&callbackURL=...` (the + * `/api/auth` prefix is Better Auth's default `basePath`; the catch-all + * route at `app/api/auth/[...all]/route.ts` just mounts `auth.handler` + * at that path via `toNextJsHandler` and doesn't add the prefix itself). + * After the user clicks the link, Better Auth consumes the token at that + * endpoint and 302s to the `callbackURL`. The default callbackURL is `/`, + * which silently drops the user on the landing page with no feedback that + * verification succeeded. + * + * We only touch the `callbackURL` query param — the verification endpoint + * path MUST stay intact, otherwise the token is never consumed and the + * user is never verified. + */ +export function verificationRedirectUrl(rawUrl: string): string { + const u = new URL(rawUrl); + u.searchParams.set("callbackURL", "/login/verified"); + return u.toString(); +} diff --git a/tests/api/auth/verify-email.test.ts b/tests/api/auth/verify-email.test.ts new file mode 100644 index 0000000..fd1b0c8 --- /dev/null +++ b/tests/api/auth/verify-email.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { verificationRedirectUrl } from "@/lib/auth/config"; + +// Better Auth constructs the verification link as +// `${baseURL}/api/auth/verify-email?token=...&callbackURL=...` (the +// `/api/auth` prefix is Better Auth's default `basePath`; the catch-all +// route at `app/api/auth/[...all]/route.ts` mounts the handler there). +// After the user clicks the link, Better Auth consumes the token at that +// endpoint and 302s to the callbackURL. Default callbackURL is `/` — +// silently bounces the user. We only want to swap the callbackURL value; +// the path must stay /api/auth/verify-email or the token never gets +// consumed and verification never happens. +describe("verificationRedirectUrl", () => { + it("rewrites callbackURL=/ to /login/verified", () => { + const out = verificationRedirectUrl( + "http://localhost:3000/api/auth/verify-email?token=abc&callbackURL=%2F", + ); + const u = new URL(out); + expect(u.searchParams.get("callbackURL")).toBe("/login/verified"); + }); + + it("rewrites callbackURL=/login/sign-in to /login/verified", () => { + const out = verificationRedirectUrl( + "http://localhost:3000/api/auth/verify-email?token=abc&callbackURL=%2Flogin%2Fsign-in", + ); + const u = new URL(out); + expect(u.searchParams.get("callbackURL")).toBe("/login/verified"); + }); + + it("adds callbackURL when missing", () => { + const out = verificationRedirectUrl("http://localhost:3000/api/auth/verify-email?token=abc"); + const u = new URL(out); + expect(u.searchParams.get("callbackURL")).toBe("/login/verified"); + }); + + it("preserves the verification endpoint path", () => { + const out = verificationRedirectUrl( + "http://localhost:3000/api/auth/verify-email?token=abc&callbackURL=%2F", + ); + const u = new URL(out); + // CRITICAL: replacing the path would skip verification — the token + // would never be consumed and the user would land on a success page + // for an unverified email. + expect(u.pathname).toBe("/api/auth/verify-email"); + }); + + it("preserves the token query param", () => { + const out = verificationRedirectUrl( + "http://localhost:3000/api/auth/verify-email?token=long-token-string&callbackURL=%2F", + ); + const u = new URL(out); + expect(u.searchParams.get("token")).toBe("long-token-string"); + }); + + it("URL-encodes the new callbackURL value", () => { + const out = verificationRedirectUrl( + "http://localhost:3000/api/auth/verify-email?token=abc&callbackURL=%2F", + ); + // /login/verified → %2Flogin%2Fverified in the raw query string. + expect(out).toContain("callbackURL=%2Flogin%2Fverified"); + }); +}); diff --git a/tests/frontend/login/verified-view.test.tsx b/tests/frontend/login/verified-view.test.tsx new file mode 100644 index 0000000..22ee7eb --- /dev/null +++ b/tests/frontend/login/verified-view.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, act } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; + +const replace = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace }), +})); + +import { VerifiedView } from "@/app/login/verified/verified-view"; + +// Mirrors the better-auth-ui card pattern: same Card + CardHeader + +// CardTitle + CardContent primitives, same outer `bg-muted/30` shell, same +// FieldDescription style for secondary text. The page itself is a server +// component (tested via Playwright); these tests pin the visible surface of +// the client view so a refactor that drops the success message or rewrites +// the manual link target surfaces here. + +describe("VerifiedView", () => { + afterEach(() => { + cleanup(); + replace.mockReset(); + vi.useRealTimers(); + }); + + it("renders the success heading, signed-in copy, and chat-bound CTA", () => { + render(); + + expect(screen.getByText(/email verified/i)).toBeInTheDocument(); + expect(screen.getByText(/you're signed in/i)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /chat now/i })).toHaveAttribute("href", "/chat"); + expect(screen.getByText(/redirecting in 5s/i)).toBeInTheDocument(); + }); + + it("renders the success icon", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("redirects via router.replace to /chat at the 5-second mark", async () => { + vi.useFakeTimers(); + render(); + + // Initial render schedules setTimeout — replace must not fire yet. + expect(replace).not.toHaveBeenCalled(); + + // Advance in 1s ticks. Each tick fires the setRemaining callback, then + // we await act() so React re-renders and re-runs the effect with the + // new remaining value (the effect's dep is `[remaining, router]`). + // Without draining React between advances, all 5 timers fire against + // stale `remaining=5` state and the final router.replace never triggers. + // + // 5 ticks exactly — the 5th decrements remaining to 0 and the effect's + // `if (remaining <= 0)` branch fires router.replace. Pinning this + // count catches a regression where the redirect drifts to 6s. + for (let i = 0; i < 5; i++) { + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + } + + expect(replace).toHaveBeenCalledTimes(1); + expect(replace).toHaveBeenCalledWith("/chat"); + }); +});