From 541d658fe3913e6debd69269b859bac63bc60b2e Mon Sep 17 00:00:00 2001 From: FireTable Date: Sat, 11 Jul 2026 01:08:58 +0800 Subject: [PATCH 1/4] feat(auth): add /login/verified success page for email verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #9: after clicking the verification link, Better Auth 302s to the configured callbackURL (default '/') and verified users land on the bare sign-in form with no feedback that their email just got verified. Two config changes + a new page make the success UX reachable: - verificationRedirectUrl(rawUrl) uses the URL API to swap ONLY the callbackURL query param to '/login/verified'. Touching the path would skip /api/auth/verify-email entirely — the token would never be consumed and verification would silently fail. - emailVerification.autoSignInAfterVerification is now on. Better Auth 1.6.x defaults this to falsy, which combined with the bare-URL redirect means /login/verified receives no token AND no session, hitting the !token && !session fallback and bouncing users back to /login. - New page mirrors the better-auth-ui card pattern (CardHeader centered icon, CardTitle, FieldDescription). CTA uses Button asChild size='lg' with MessagesSquareIcon to match components/landing/hero-cta.tsx. Tests cover URL transform (callbackURL=/, callbackURL=/login/sign-in, missing param, path preservation, token preservation, URL encoding) and the two CTA branches (hasSession=true → /chat, false → /login). Co-Authored-By: Claude Opus 4.8 --- app/login/verified/page.tsx | 29 ++++++++++ app/login/verified/verified-view.tsx | 63 +++++++++++++++++++++ docs/AUTH.md | 13 +++-- lib/auth/config.ts | 33 ++++++++++- tests/api/auth/verify-email.test.ts | 59 +++++++++++++++++++ tests/frontend/login/verified-view.test.tsx | 48 ++++++++++++++++ 6 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 app/login/verified/page.tsx create mode 100644 app/login/verified/verified-view.tsx create mode 100644 tests/api/auth/verify-email.test.ts create mode 100644 tests/frontend/login/verified-view.test.tsx diff --git a/app/login/verified/page.tsx b/app/login/verified/page.tsx new file mode 100644 index 0000000..fb6d91a --- /dev/null +++ b/app/login/verified/page.tsx @@ -0,0 +1,29 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { auth } from "@/lib/auth/config"; + +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). By the time the +// user hits this page, the token has already been consumed and (with +// autoSignInAfterVerification, which Better Auth defaults to true) a session +// is established. This page is purely a success UX — confirmation, 5s +// auto-redirect, manual link. +// +// If someone lands here directly (no token, no session — e.g. bookmark or +// refresh after session expiry), we redirect to /login immediately so they +// don't see a misleading "verified" message for an email they didn't verify. +type SearchParams = Promise<{ token?: string }>; + +export default async function VerifiedPage({ searchParams }: { searchParams: SearchParams }) { + const { token } = await searchParams; + const session = await auth.api.getSession({ headers: await headers() }); + + if (!token && !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..e31cdd9 --- /dev/null +++ b/app/login/verified/verified-view.tsx @@ -0,0 +1,63 @@ +"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; + +export function VerifiedView({ hasSession }: { hasSession: boolean }) { + const router = useRouter(); + const [remaining, setRemaining] = useState(REDIRECT_SECONDS); + + // lib/auth/config.ts sets autoSignInAfterVerification: true, so a fresh + // verification lands here with a real session → CTA → /chat. + // hasSession=false is the rare case: direct bookmark/refresh visits, + // where there's no verification flow in flight and the user still needs + // to sign in. + const target = hasSession ? "/chat" : "/login"; + + useEffect(() => { + if (remaining <= 0) { + router.replace(target); + return; + } + const t = setTimeout(() => setRemaining((s) => s - 1), 1000); + return () => clearTimeout(t); + }, [remaining, router, target]); + + return ( +
+ + + + Email verified + + + +
+ + Your email is confirmed. {hasSession ? "You're signed in." : "You can now sign 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..fa3766f 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,22 @@ 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}/verify-email?token=...&callbackURL=...`. After the user clicks + * the link, Better Auth consumes the token at `/verify-email` 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 path MUST stay + * `/verify-email`, 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..8ffc5fd --- /dev/null +++ b/tests/api/auth/verify-email.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { verificationRedirectUrl } from "@/lib/auth/config"; + +// Better Auth constructs the verification link as +// `${baseURL}/verify-email?token=...&callbackURL=...`. After the user clicks +// the link, Better Auth consumes the token at /verify-email and 302s to the +// callbackURL. Default callbackURL is `/` — silently bounces the user. We +// only want to swap the callbackURL value; the path must stay /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/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/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/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/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("/verify-email"); + }); + + it("preserves the token query param", () => { + const out = verificationRedirectUrl( + "http://localhost:3000/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/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..f27e01d --- /dev/null +++ b/tests/frontend/login/verified-view.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } 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(); + }); + + describe("with a session (auto-signed in)", () => { + it("renders the success heading and the chat-bound CTA", () => { + render(); + + expect(screen.getByText(/email verified/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(); + }); + }); + + describe("without a session (Better Auth default — must sign in)", () => { + it("renders the success heading and the sign-in-bound CTA", () => { + render(); + + expect(screen.getByText(/email verified/i)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /chat now/i })).toHaveAttribute("href", "/login"); + }); + }); +}); From b36bba7bf625ed9c2568ef8f47b77ec3c0ccde7a Mon Sep 17 00:00:00 2001 From: FireTable Date: Sat, 11 Jul 2026 01:09:49 +0800 Subject: [PATCH 2/4] chore: gitignore .playwright-screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local visual verification screenshots. Matches the existing convention with /.verify and /.observability-screens — chrome-devtools MCP screenshots land here when verifying auth UI changes. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 711937dba2486dc9d5a63dac1b393de1a427d2e9 Mon Sep 17 00:00:00 2001 From: FireTable Date: Sat, 11 Jul 2026 01:33:13 +0800 Subject: [PATCH 3/4] fix(auth): drop verified-page token guard and adopt session helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-driven cleanup from PR #33. claude[bot] and greptile both flagged the same guard: if (!token && !session) redirect('/login') The token check is meaningless here: Better Auth consumes the token server-side at /api/auth/verify-email and the 302 to callbackURL is the bare URL value, so searchParams.token is never set in the normal flow. Any visitor with ?token=foo bypassed the redirect and rendered the success view with hasSession=false, which then misleads the user via the 'Chat Now → /login' CTA. Drop the token lookup entirely. session is the only reliable signal; autoSignInAfterVerification is on, so a verified user always lands here with a real cookie. Also: - Adopt getSessionFromHeaders() from lib/auth/queries (claude[bot] style nit; matches app/chat/page.tsx and sidesteps Next.js dynamic-API double-await if the call later gets wrapped in cache()). - Add vi.useFakeTimers() countdown test — assert router.replace fires with /chat after the 5 ticks (claude[bot] test gap). - Pin the 'You're signed in.' / 'You can now sign in.' copy on both branches. - Fix the verification endpoint path in lib/auth/config.ts comment ('/verify-email' → '/api/auth/verify-email') — the function only touches callbackURL so behavior is correct, prose just matched the test fixtures (claude[bot] doc nit). Reject greptile's 'Chat Now' misleading label: copy matches components/landing/hero-cta.tsx by design, and the page no longer renders the no-session branch anyway. Co-Authored-By: Claude Opus 4.8 --- app/login/verified/page.tsx | 29 +++++------- lib/auth/config.ts | 17 ++++--- tests/frontend/login/verified-view.test.tsx | 49 +++++++++++++++++++-- 3 files changed, 66 insertions(+), 29 deletions(-) diff --git a/app/login/verified/page.tsx b/app/login/verified/page.tsx index fb6d91a..23a29de 100644 --- a/app/login/verified/page.tsx +++ b/app/login/verified/page.tsx @@ -1,29 +1,22 @@ -import { headers } from "next/headers"; import { redirect } from "next/navigation"; -import { auth } from "@/lib/auth/config"; +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). By the time the -// user hits this page, the token has already been consumed and (with -// autoSignInAfterVerification, which Better Auth defaults to true) a session -// is established. This page is purely a success UX — confirmation, 5s -// auto-redirect, manual link. -// -// If someone lands here directly (no token, no session — e.g. bookmark or -// refresh after session expiry), we redirect to /login immediately so they -// don't see a misleading "verified" message for an email they didn't verify. -type SearchParams = Promise<{ token?: string }>; +// (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(); -export default async function VerifiedPage({ searchParams }: { searchParams: SearchParams }) { - const { token } = await searchParams; - const session = await auth.api.getSession({ headers: await headers() }); - - if (!token && !session) { + if (!session) { redirect("/login"); } - return ; + return ; } diff --git a/lib/auth/config.ts b/lib/auth/config.ts index fa3766f..83943d6 100644 --- a/lib/auth/config.ts +++ b/lib/auth/config.ts @@ -104,14 +104,17 @@ 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}/verify-email?token=...&callbackURL=...`. After the user clicks - * the link, Better Auth consumes the token at `/verify-email` and 302s to - * the `callbackURL`. The default callbackURL is `/`, which silently drops - * the user on the landing page with no feedback that verification succeeded. + * `${baseURL}/api/auth/verify-email?token=...&callbackURL=...` (the + * `/api/auth` prefix comes from the catch-all proxy in + * `app/api/auth/[...all]/route.ts`). 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 path MUST stay - * `/verify-email`, otherwise the token is never consumed and the user is - * never verified. + * 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); diff --git a/tests/frontend/login/verified-view.test.tsx b/tests/frontend/login/verified-view.test.tsx index f27e01d..b0916f8 100644 --- a/tests/frontend/login/verified-view.test.tsx +++ b/tests/frontend/login/verified-view.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { render, screen, cleanup, act } from "@testing-library/react"; import "@testing-library/jest-dom/vitest"; const replace = vi.fn(); @@ -20,13 +20,15 @@ describe("VerifiedView", () => { afterEach(() => { cleanup(); replace.mockReset(); + vi.useRealTimers(); }); describe("with a session (auto-signed in)", () => { - it("renders the success heading and the chat-bound CTA", () => { + 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(); }); @@ -35,14 +37,53 @@ describe("VerifiedView", () => { const { container } = render(); expect(container.querySelector("svg")).toBeInTheDocument(); }); + + it("redirects via router.replace after the 5s countdown", 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, target]`). + // Without draining React between advances, all 5 timers fire against + // stale `remaining=5` state and the final router.replace never triggers. + for (let i = 0; i < 6; i++) { + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + } + + expect(replace).toHaveBeenCalledTimes(1); + expect(replace).toHaveBeenCalledWith("/chat"); + }); }); - describe("without a session (Better Auth default — must sign in)", () => { - it("renders the success heading and the sign-in-bound CTA", () => { + describe("without a session (defensive fallback — page no longer renders this)", () => { + // The server page redirects to /login when !session, so hasSession=false + // is unreachable in practice. Pin the visible surface anyway so the + // fallback branch stays correct if someone re-wires the page. + it("renders the success heading, sign-in copy, and sign-in-bound CTA", () => { render(); expect(screen.getByText(/email verified/i)).toBeInTheDocument(); + expect(screen.getByText(/you can now sign in/i)).toBeInTheDocument(); expect(screen.getByRole("link", { name: /chat now/i })).toHaveAttribute("href", "/login"); }); + + it("redirects via router.replace to /login after the countdown", async () => { + vi.useFakeTimers(); + render(); + + for (let i = 0; i < 6; i++) { + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + } + + expect(replace).toHaveBeenCalledWith("/login"); + }); }); }); From 8523375b698200f4b77e4265617e06ab64dd688a Mon Sep 17 00:00:00 2001 From: FireTable Date: Sat, 11 Jul 2026 01:54:36 +0800 Subject: [PATCH 4/4] refactor(auth): remove dead hasSession branch and update verify-email docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-driven cleanup from PR #33's second wave (claude[bot], greptile, cubic re-triggered after the 711937d push). - Drop the `hasSession` prop from `VerifiedView` (cubic 3560907467, claude[bot] 3560868790/3560871871). The page redirects to /login when !session, so the false branch is unreachable in production. Removing it also deletes the misleading 'Chat Now → /login' and 'You can now sign in.' surface that greptile flagged in 3560735456. - Tighten the countdown test: advance 5 ticks (i < 5), not 6 (cubic 3560907477). The 6th tick was defensive padding; with it, a 1-second redirect regression would still pass. - Update `lib/auth/config.ts` doc comment: `/api/auth` prefix is Better Auth's default `basePath`, the catch-all route is just a mount point (cubic 3560907491). - Sync the test docblock + URL fixtures in `tests/api/auth/verify-email.test.ts` to `/api/auth/verify-email` (claude[bot] 3560868448/3560868562) so the prose matches the actual endpoint the function sees at runtime. Reject claude[bot] 3560868685 (autoSignIn security observation): the trade-off is already called out in the ponytail comment above the flag in `lib/auth/config.ts`. Email link is the auth token either way; the behavior matches Better Auth's docs and the project's CLAUDE.md security model. No follow-up. Co-Authored-By: Claude Opus 4.8 --- app/login/verified/page.tsx | 2 +- app/login/verified/verified-view.tsx | 25 +++--- lib/auth/config.ts | 13 +-- tests/api/auth/verify-email.test.ts | 27 +++--- tests/frontend/login/verified-view.test.tsx | 92 ++++++++------------- 5 files changed, 68 insertions(+), 91 deletions(-) diff --git a/app/login/verified/page.tsx b/app/login/verified/page.tsx index 23a29de..ba8d345 100644 --- a/app/login/verified/page.tsx +++ b/app/login/verified/page.tsx @@ -18,5 +18,5 @@ export default async function VerifiedPage() { redirect("/login"); } - return ; + return ; } diff --git a/app/login/verified/verified-view.tsx b/app/login/verified/verified-view.tsx index e31cdd9..f6e7255 100644 --- a/app/login/verified/verified-view.tsx +++ b/app/login/verified/verified-view.tsx @@ -11,25 +11,22 @@ import { FieldDescription } from "@/components/ui/field"; const REDIRECT_SECONDS = 5; -export function VerifiedView({ hasSession }: { hasSession: boolean }) { +// 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); - // lib/auth/config.ts sets autoSignInAfterVerification: true, so a fresh - // verification lands here with a real session → CTA → /chat. - // hasSession=false is the rare case: direct bookmark/refresh visits, - // where there's no verification flow in flight and the user still needs - // to sign in. - const target = hasSession ? "/chat" : "/login"; - useEffect(() => { if (remaining <= 0) { - router.replace(target); + router.replace("/chat"); return; } const t = setTimeout(() => setRemaining((s) => s - 1), 1000); return () => clearTimeout(t); - }, [remaining, router, target]); + }, [remaining, router]); return (
@@ -40,17 +37,17 @@ export function VerifiedView({ hasSession }: { hasSession: boolean }) { -
+
- Your email is confirmed. {hasSession ? "You're signed in." : "You can now sign in."} + Your email is confirmed. You're signed in. {remaining > 0 ? `Redirecting in ${remaining}s…` : "Redirecting…"} -