diff --git a/.changeset/11633-verify-email-get.md b/.changeset/11633-verify-email-get.md new file mode 100644 index 0000000000..dffa5494cb --- /dev/null +++ b/.changeset/11633-verify-email-get.md @@ -0,0 +1,9 @@ +--- +'@object-ui/console': patch +--- + +The console's `/verify-email?token=…` page verifies the address again (objectui#11633). It used to send the token as `POST /api/v1/auth/verify-email` with a JSON body. better-auth serves that route as GET only, so the server answered 404. Every valid token then showed "Verification failed: 404", and the account stayed unverified. + +The page now calls `GET /api/v1/auth/verify-email?token=…`, the route the server already serves and the one the mailed link targets. It sends no `callbackURL`, so the route answers JSON instead of redirecting. The page shows the success state only for that JSON receipt (`{ status: true }`), which the route also returns when the address is already verified. A garbage or expired token gets a 401 from the server, and the page shows the error state with the server's reason. A 2xx that is not the receipt, such as an HTML page, also shows the error state. The page's states, copy and links are unchanged. + +**Clause-②: no.** Nothing on any package entry changes. No export, prop, type member or i18n key is added or removed. diff --git a/apps/console/src/pages/auth/VerifyEmailPage.tsx b/apps/console/src/pages/auth/VerifyEmailPage.tsx index 2b4a8366f3..df5677111f 100644 --- a/apps/console/src/pages/auth/VerifyEmailPage.tsx +++ b/apps/console/src/pages/auth/VerifyEmailPage.tsx @@ -1,11 +1,11 @@ /** * VerifyEmailPage — Console-hosted email-verification landing page. * - * Ported from `framework/apps/account/src/routes/verify-email.tsx`. The - * user hits this URL after clicking the link in the verification email: - * `?token=…` is consumed on mount via `POST /api/v1/auth/verify-email` - * (better-auth's standard endpoint). `useAuth()` doesn't expose a - * `verifyEmail()` so we call the REST endpoint directly. + * Ported from `framework/apps/account/src/routes/verify-email.tsx`. A user + * who opens `/verify-email?token=…` has `?token=` consumed on mount via + * `GET /api/v1/auth/verify-email?token=…` (better-auth's standard endpoint, + * served as GET only). `useAuth()` doesn't expose a `verifyEmail()` so we + * call the REST endpoint directly. */ import { useEffect, useState } from 'react'; @@ -44,21 +44,27 @@ export function VerifyEmailPage() { let cancelled = false; (async () => { try { - // better-auth exposes verify-email as a GET with `?token=` *or* a - // POST with `{ token }` body. The GET variant 302-redirects on - // success; the POST variant returns JSON. We use POST so the SPA - // controls the post-verify UX. - const res = await fetch(`${AUTH_BASE}/verify-email`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + // better-auth serves verify-email as `GET ?token=` only; a POST + // answers 404 (objectui#11633). Sent WITHOUT `callbackURL` the route + // answers JSON instead of a 302: `{ status: true, user }` once the + // token verifies (again on a repeat), and a 401 `{ code, message }` + // for a garbage or expired token. So the SPA keeps the post-verify + // UX, and only that JSON receipt counts as success: a 2xx that is + // not it (an HTML page, a followed redirect's landing) is an error. + const query = new URLSearchParams({ token }); + const res = await fetch(`${AUTH_BASE}/verify-email?${query}`, { + method: 'GET', credentials: 'include', - body: JSON.stringify({ token }), }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); + const data = (await res.json().catch(() => null)) as { + status?: unknown; + message?: unknown; + } | null; + if (!res.ok || data?.status !== true) { + const serverMessage = + !res.ok && typeof data?.message === 'string' ? data.message : ''; throw new Error( - (data as { message?: string })?.message || - `Verification failed: ${res.status}`, + serverMessage || (res.ok ? '' : `Verification failed: ${res.status}`), ); } if (!cancelled) setStatus('success'); diff --git a/apps/console/src/pages/auth/__tests__/VerifyEmailPage-11633.test.tsx b/apps/console/src/pages/auth/__tests__/VerifyEmailPage-11633.test.tsx new file mode 100644 index 0000000000..2cda97338a --- /dev/null +++ b/apps/console/src/pages/auth/__tests__/VerifyEmailPage-11633.test.tsx @@ -0,0 +1,132 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11633 — `/verify-email?token=…` verifies through the GET route + * better-auth actually serves. + * + * better-auth declares `/verify-email` as `method: "GET"` only, and the + * framework's auth route ledger lists only `GET /api/v1/auth/verify-email`. + * The page used to POST `{ token }`, which the server answers 404, so every + * valid token rendered the error state and the account stayed unverified. + * + * The stub answers the way the live route answers a request that carries no + * `callbackURL`: a JSON receipt `{ status: true, user: null }` for a token + * that verifies, and a 401 `{ code, message }` for a garbage or expired one. + * Any other method is a 404, as on the server. A `callbackURL` would turn the + * answer into a 302 whose followed landing is an HTML page, so the stub serves + * that page to such a request, and the page must not send one. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { VerifyEmailPage } from '../VerifyEmailPage'; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const VALID = 'eyJhbGciOiJIUzI1NiJ9.valid+token/with=chars'; +const EXPIRED = 'eyJhbGciOiJIUzI1NiJ9.expired'; +const GARBAGE = 'not-a-jwt'; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +interface Call { + url: URL; + init: RequestInit | undefined; +} + +/** A server that answers `/api/v1/auth/verify-email` like better-auth does. */ +function verifyEmailServer(opts: { htmlOk?: boolean } = {}) { + const calls: Call[] = []; + const fetchStub = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const url = new URL(raw, 'http://localhost'); + calls.push({ url, init }); + const method = (init?.method ?? 'GET').toUpperCase(); + if (url.pathname !== '/api/v1/auth/verify-email' || method !== 'GET') { + return new Response(null, { status: 404 }); + } + if (opts.htmlOk || url.searchParams.has('callbackURL')) { + return new Response('console', { + status: 200, + headers: { 'Content-Type': 'text/html' }, + }); + } + const token = url.searchParams.get('token'); + if (token === VALID) return json(200, { status: true, user: null }); + if (token === EXPIRED) return json(401, { code: 'TOKEN_EXPIRED', message: 'Token expired' }); + return json(401, { code: 'INVALID_TOKEN', message: 'Invalid token' }); + }); + vi.stubGlobal('fetch', fetchStub); + return calls; +} + +function renderAt(search: string) { + return render( + + + + + , + ); +} + +describe('VerifyEmailPage verifies through GET ?token= (objectui#11633)', () => { + it('a valid token reaches the success state through a GET that carries it in the query', async () => { + const calls = verifyEmailServer(); + renderAt(`?token=${encodeURIComponent(VALID)}`); + + expect(await screen.findByText('Email verified')).toBeInTheDocument(); + expect(screen.queryByText('Verification failed')).toBeNull(); + + expect(calls).toHaveLength(1); + const [{ url, init }] = calls; + expect(url.pathname).toBe('/api/v1/auth/verify-email'); + expect((init?.method ?? 'GET').toUpperCase()).toBe('GET'); + expect(init?.body).toBeUndefined(); + expect(url.searchParams.get('token')).toBe(VALID); + // Without callbackURL the route answers its JSON receipt, not a 302. + expect(url.searchParams.has('callbackURL')).toBe(false); + }); + + it('a garbage token renders the error state with the server reason', async () => { + verifyEmailServer(); + renderAt(`?token=${GARBAGE}`); + + expect(await screen.findByText('Verification failed')).toBeInTheDocument(); + expect(screen.getByText('Invalid token')).toBeInTheDocument(); + expect(screen.queryByText('Email verified')).toBeNull(); + }); + + it('an expired token renders the error state', async () => { + verifyEmailServer(); + renderAt(`?token=${EXPIRED}`); + + expect(await screen.findByText('Verification failed')).toBeInTheDocument(); + expect(screen.getByText('Token expired')).toBeInTheDocument(); + expect(screen.queryByText('Email verified')).toBeNull(); + }); + + it('a 2xx that is not the JSON receipt (an HTML page) is not a success', async () => { + verifyEmailServer({ htmlOk: true }); + renderAt(`?token=${encodeURIComponent(VALID)}`); + + expect(await screen.findByText('Verification failed')).toBeInTheDocument(); + expect(screen.queryByText('Email verified')).toBeNull(); + }); +});