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
9 changes: 9 additions & 0 deletions .changeset/11633-verify-email-get.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 23 additions & 17 deletions apps/console/src/pages/auth/VerifyEmailPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
Expand Down
132 changes: 132 additions & 0 deletions apps/console/src/pages/auth/__tests__/VerifyEmailPage-11633.test.tsx
Original file line number Diff line number Diff line change
@@ -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('<!doctype html><html><body>console</body></html>', {
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(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
<MemoryRouter initialEntries={[`/verify-email${search}`]}>
<VerifyEmailPage />
</MemoryRouter>
</I18nProvider>,
);
}

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();
});
});
Loading