diff --git a/.changeset/10893-signup-carries-verification-callback.md b/.changeset/10893-signup-carries-verification-callback.md new file mode 100644 index 0000000000..7d5f76b138 --- /dev/null +++ b/.changeset/10893-signup-carries-verification-callback.md @@ -0,0 +1,32 @@ +--- +'@object-ui/auth': minor +'@object-ui/console': patch +--- + +fix(auth, console): a registration started from an invitation link comes back to the invitation after email verification + +better-auth builds the verification mail's link server-side from the +`/sign-up/email` request's `callbackURL`, defaulting it to `/`. The console kept +`?redirect=/accept-invitation/ID` from the login page through `/register` to the +"check your inbox" screen, but `createAuthClient().signUp` never sent a +`callbackURL`, so the mail read `callbackURL=/` and the invitee verified onto the +workspace picker ("Create workspace") instead of the invitation. + +`@object-ui/auth` (additive): + +- `SignUpData.callbackURL` is forwarded verbatim to `/sign-up/email`. Absent, the + key stays off the wire and the server default is untouched. +- `useAuth().signUp(name, email, password, callbackURL?)` takes it as an optional + fourth argument. +- `RegisterForm` gains a `verificationCallbackURL` prop, forwarded to `signUp`. + +`@object-ui/console`: + +- `RegisterPage` passes a safe `?redirect=` as the verification callback, and the + verify-email prompt's "Resend" sends the same value. Before, Resend sent the + bare router path, which the server redirects to at the ORIGIN root, outside + the `/_console` mount. +- Both resolve the route through the new `withConsoleBaseRootRelative`. The server + never sees ``, and better-auth refuses a document-relative `./…` + callback — what `withConsoleBase` answers in the embedded build — with + `403 INVALID_CALLBACK_URL`, failing the whole sign-up. diff --git a/apps/console/src/pages/auth/RegisterPage.tsx b/apps/console/src/pages/auth/RegisterPage.tsx index 17f28ac7dd..c9917f2202 100644 --- a/apps/console/src/pages/auth/RegisterPage.tsx +++ b/apps/console/src/pages/auth/RegisterPage.tsx @@ -8,7 +8,8 @@ * - Bounces to `/login` if `emailPassword.disableSignUp === true` * (defense-in-depth; the server-side gate is the source of truth). * - Routes to `/verify-email-prompt` when the server requires email - * verification before sign-in. + * verification before sign-in, and carries `?redirect=` into the + * verification mail's link so it survives the inbox (objectui#10893). * - Replays an `/oauth2/authorize` query string when the user landed * here mid-SSO so the IdP can continue the flow post-signup. */ @@ -22,7 +23,7 @@ import { AuthLayout } from './AuthLayout'; import { followOauthAuthorize } from './followAuthorize'; // Was a second module-private copy of LoginPage's helper; both now share one // implementation — objectui#4181. Behaviour here is unchanged. -import { withConsoleBase } from '../../utils/consoleBase'; +import { withConsoleBase, withConsoleBaseRootRelative } from '../../utils/consoleBase'; function isSafeRedirect(target: string | null): target is string { return !!target && target.startsWith('/') && !target.startsWith('//'); @@ -141,6 +142,13 @@ export function RegisterPage() { } const loginUrl = redirect ? `/login?redirect=${encodeURIComponent(redirect)}` : '/login'; + // objectui#10893 — when the server gates sign-in on email verification, the + // mail's link is the only road back, so it must carry `?redirect=` (e.g. the + // invitation the user registered from). Only a safe in-app target is + // forwarded; anything else would be refused by the server and fail sign-up. + const verificationCallbackURL = isSafeRedirect(redirect) + ? withConsoleBaseRootRelative(redirect) + : undefined; return ( @@ -151,6 +159,7 @@ export function RegisterPage() { defaultValue: 'Create your account to start building.', })} loginUrl={loginUrl} + verificationCallbackURL={verificationCallbackURL} linkComponent={RouterLink} errorMessages={{ USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: t('auth.register.errors.userExists', { diff --git a/apps/console/src/pages/auth/VerifyEmailPromptPage.tsx b/apps/console/src/pages/auth/VerifyEmailPromptPage.tsx index d1bd1af140..f8c800a718 100644 --- a/apps/console/src/pages/auth/VerifyEmailPromptPage.tsx +++ b/apps/console/src/pages/auth/VerifyEmailPromptPage.tsx @@ -21,6 +21,7 @@ import { CardTitle, } from '@object-ui/components'; import { AuthLayout } from './AuthLayout'; +import { withConsoleBaseRootRelative } from '../../utils/consoleBase'; export function VerifyEmailPromptPage() { const { t } = useObjectTranslation(); @@ -56,7 +57,16 @@ export function VerifyEmailPromptPage() { setResending(true); setResendError(null); try { - await sendVerificationEmail(email, redirect || '/'); + // objectui#10893 — `redirect` is a router path (basename-stripped). The + // server redirects to this value verbatim after verification, so it + // must be resolved against the console mount first: the bare route + // landed at the ORIGIN root, outside `/_console`. No safe target means + // no callbackURL, and the server default applies, as it did before. + const callbackURL = + redirect.startsWith('/') && !redirect.startsWith('//') + ? withConsoleBaseRootRelative(redirect) + : undefined; + await sendVerificationEmail(email, callbackURL); setResent(true); toast.success( t('auth.verifyEmail.resentSuccess', { diff --git a/apps/console/src/pages/auth/__tests__/verificationCallback-10893.test.tsx b/apps/console/src/pages/auth/__tests__/verificationCallback-10893.test.tsx new file mode 100644 index 0000000000..b49cf8a799 --- /dev/null +++ b/apps/console/src/pages/auth/__tests__/verificationCallback-10893.test.tsx @@ -0,0 +1,205 @@ +/** + * objectui#10893 — an invitee who registers from an invitation link must be + * brought back to the invitation by the verification mail. + * + * The console kept `?redirect=/accept-invitation/ID` from the login page through + * `/register` to `/verify-email-prompt`, but the verification mail is built + * server-side from the sign-up request's `callbackURL`, which nothing sent: the + * link read `callbackURL=/` and the invitee verified onto the workspace picker. + * + * Two console seams feed that value: + * + * - `RegisterPage` → `RegisterForm.verificationCallbackURL` → `signUp`'s 4th + * argument (the first mail); + * - `VerifyEmailPromptPage` → `sendVerificationEmail`'s 2nd argument (the + * "Resend" mail), which forwarded the bare router path and so pointed at the + * ORIGIN root, outside the console mount. + * + * Both must hand the server a ROOT-relative url inside the mount. The server + * never sees ``; better-auth refuses a document-relative `./…` with + * `403 INVALID_CALLBACK_URL`, failing the whole sign-up. So the embedded mount + * (whose `withConsoleBase` answer is `./…`) is the case that matters most. + * + * Nothing in `@object-ui/auth` is replaced: a real `AuthProvider` over a real + * `createAuthClient` runs against a stub server, and every assertion reads the + * REQUEST BODY the server would receive — the value better-auth writes into the + * mail's link. So the page, the shipped `RegisterForm`, `useAuth`, the provider + * and the client are all on the path; dropping the value at any hop turns these + * red. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BrowserRouter } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { AuthProvider, createAuthClient } from '@object-ui/auth'; + +vi.mock('sonner', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, toast: { success: vi.fn(), error: vi.fn() } }; +}); + +const { RegisterPage } = await import('../RegisterPage'); +const { VerifyEmailPromptPage } = await import('../VerifyEmailPromptPage'); + +/** The three configurations the console ships in (see authExitBasename.test). */ +const MOUNTS = { + standalone: { href: null, baseUrl: '/', basename: '/', prefix: '' }, + embedded: { href: '/_console/', baseUrl: './', basename: '/_console', prefix: '/_console' }, + pinned: { href: '/_console/', baseUrl: '/_console/', basename: '/_console', prefix: '/_console' }, +} as const; +type MountName = keyof typeof MOUNTS; + +let baseEl: HTMLBaseElement | null = null; + +function mountConsole(name: MountName, at: string) { + const mount = MOUNTS[name]; + baseEl?.remove(); + baseEl = null; + if (mount.href) { + baseEl = document.createElement('base'); + baseEl.setAttribute('href', mount.href); + document.head.appendChild(baseEl); + } + vi.stubEnv('BASE_URL', mount.baseUrl); + window.history.replaceState({}, '', `${mount.prefix}${at}`); + return mount; +} + +const AUTH_URL = 'http://localhost/api/v1/auth'; +type WireBodies = Record<'signUp' | 'resend', Array>>; +let wire: WireBodies; + +/** + * A signed-out visitor on a server that gates sign-in on email verification: + * no session, `/sign-up/email` answers with a null token. + */ +function verificationGatedServer(): typeof fetch { + wire = { signUp: [], resend: [] }; + const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + return (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url.includes('/sign-up/email')) { + wire.signUp.push(JSON.parse(String(init?.body))); + return json({ user: { id: 'u_new', name: 'Wang Wei', email: 'wangwei@example.com' }, token: null }); + } + if (url.includes('/send-verification-email')) { + wire.resend.push(JSON.parse(String(init?.body))); + return json({ status: true }); + } + if (url.endsWith('/config')) return json({}); + return json(null); + }) as typeof fetch; +} + +function renderAt(basename: string, ui: React.ReactElement) { + const client = createAuthClient({ baseURL: AUTH_URL, fetchFn: verificationGatedServer() }); + return render( + + + {ui} + + , + ); +} + +/** What the server would be asked to redirect to after verification. */ +async function signUpCallback(name: MountName, search: string): Promise { + const mount = mountConsole(name, `/register${search}`); + renderAt(mount.basename, ); + + await userEvent.type(await screen.findByLabelText('Name'), 'Wang Wei'); + await userEvent.type(screen.getByLabelText('Email'), 'wangwei@example.com'); + await userEvent.type(screen.getByLabelText('Password'), 'hunter2hunter2'); + await userEvent.type(screen.getByLabelText('Confirm Password'), 'hunter2hunter2'); + await userEvent.click(screen.getByRole('button', { name: 'Create Account' })); + + await waitFor(() => expect(wire.signUp).toHaveLength(1)); + expect(wire.signUp[0]).toMatchObject({ name: 'Wang Wei', email: 'wangwei@example.com' }); + return wire.signUp[0].callbackURL; +} + +/** A url better-auth accepts as a callbackURL without a trusted-origin list. */ +function isRootRelative(value: unknown): boolean { + return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//'); +} + +const INVITE = '?redirect=%2Faccept-invitation%2Finv_1'; + +beforeEach(() => { + vi.spyOn(window.location, 'assign').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + baseEl?.remove(); + baseEl = null; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe('RegisterPage — the verification mail carries ?redirect= (objectui#10893)', () => { + it('embedded console: the invitation, root-relative inside the mount', async () => { + const callback = await signUpCallback('embedded', INVITE); + expect(callback).toBe('/_console/accept-invitation/inv_1'); + expect(isRootRelative(callback)).toBe(true); + }); + + it('pinned-base console: the invitation, inside the mount', async () => { + expect(await signUpCallback('pinned', INVITE)).toBe('/_console/accept-invitation/inv_1'); + }); + + it('default `/` mount: the invitation route as-is', async () => { + expect(await signUpCallback('standalone', INVITE)).toBe('/accept-invitation/inv_1'); + }); + + it('no ?redirect=: no callbackURL on the wire, so the server default is untouched', async () => { + expect(await signUpCallback('embedded', '')).toBeUndefined(); + expect(Object.keys(wire.signUp[0])).not.toContain('callbackURL'); + }); + + it('an off-site ?redirect= is not forwarded (the server would refuse it and fail sign-up)', async () => { + expect(await signUpCallback('embedded', '?redirect=%2F%2Fevil.example%2Fx')).toBeUndefined(); + }); + + it('still routes to the inbox prompt with the redirect kept', async () => { + await signUpCallback('embedded', INVITE); + await waitFor(() => expect(window.location.pathname).toBe('/_console/verify-email-prompt')); + const sp = new URLSearchParams(window.location.search); + expect(sp.get('email')).toBe('wangwei@example.com'); + expect(sp.get('redirect')).toBe('/accept-invitation/inv_1'); + }); +}); + +describe('VerifyEmailPromptPage — the resent mail carries ?redirect= too (objectui#10893)', () => { + async function resendCallback(name: MountName, search: string): Promise<[unknown, unknown]> { + const mount = mountConsole(name, `/verify-email-prompt${search}`); + renderAt(mount.basename, ); + await userEvent.click(await screen.findByRole('button', { name: /Resend verification email/ })); + await waitFor(() => expect(wire.resend).toHaveLength(1)); + return [wire.resend[0].email, wire.resend[0].callbackURL]; + } + + it('embedded console: resolves the router path into the mount', async () => { + const [email, callback] = await resendCallback('embedded', `?email=wangwei%40example.com&${INVITE.slice(1)}`); + expect(email).toBe('wangwei@example.com'); + // The pre-fix value was the bare router path '/accept-invitation/inv_1', + // which the server redirected to at the ORIGIN root, outside `/_console`. + expect(callback).toBe('/_console/accept-invitation/inv_1'); + }); + + it('default `/` mount: the invitation route as-is', async () => { + const [, callback] = await resendCallback('standalone', `?email=wangwei%40example.com&${INVITE.slice(1)}`); + expect(callback).toBe('/accept-invitation/inv_1'); + }); + + it('no ?redirect=: no callbackURL on the wire, so the server default applies', async () => { + const [, callback] = await resendCallback('embedded', '?email=wangwei%40example.com'); + expect(callback).toBeUndefined(); + expect(Object.keys(wire.resend[0])).not.toContain('callbackURL'); + }); +}); diff --git a/apps/console/src/utils/consoleBase.test.ts b/apps/console/src/utils/consoleBase.test.ts index e77e90ef0d..86079f7a24 100644 --- a/apps/console/src/utils/consoleBase.test.ts +++ b/apps/console/src/utils/consoleBase.test.ts @@ -19,7 +19,7 @@ */ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { withConsoleBase } from './consoleBase'; +import { withConsoleBase, withConsoleBaseRootRelative } from './consoleBase'; let baseEl: HTMLBaseElement | null = null; @@ -108,3 +108,44 @@ describe('withConsoleBase', () => { expect(withConsoleBase('organizations')).toBe('/_console/organizations'); }); }); + +/** + * `withConsoleBaseRootRelative` — the same route as a server-side redirect + * target (objectui#10893). The server never sees ``, and + * better-auth refuses a document-relative `./…` callbackURL with + * `403 INVALID_CALLBACK_URL` (failing the whole sign-up), so every mount must + * yield a ROOT-relative path — and it must be the path a full-page navigation + * to the same route lands on. + */ +describe('withConsoleBaseRootRelative', () => { + const ROUTE = '/accept-invitation/inv_1'; + + it('THE HAZARD it exists for: the embedded build\'s withConsoleBase answer is document-relative', () => { + mountConsole('/_console/', './'); + expect(withConsoleBase(ROUTE)).toBe('./accept-invitation/inv_1'); + }); + + it.each([ + ['the shipped embeddable build', '/_console/', './', '/_console/accept-invitation/inv_1'], + ['a pinned absolute base', '/_console/', '/_console/', '/_console/accept-invitation/inv_1'], + ['the default `/` mount', null, '/', '/accept-invitation/inv_1'], + ])('%s: root-relative, and where a navigation to the route lands', (_name, href, baseUrl, expected) => { + mountConsole(href, baseUrl); + const target = withConsoleBaseRootRelative(ROUTE); + expect(target).toBe(expected); + expect(target.startsWith('/') && !target.startsWith('//')).toBe(true); + expect(target).toBe(lands(withConsoleBase(ROUTE))); + }); + + it('keeps the query and hash of the route', () => { + mountConsole('/_console/', './'); + expect(withConsoleBaseRootRelative('/settings?tab=members#invites')).toBe( + '/_console/settings?tab=members#invites', + ); + }); + + it('leaves a target that names its own absolute SPA mount untouched', () => { + mountConsole('/_console/', './'); + expect(withConsoleBaseRootRelative('/_studio/apps')).toBe('/_studio/apps'); + }); +}); diff --git a/apps/console/src/utils/consoleBase.ts b/apps/console/src/utils/consoleBase.ts index ea5ed2b882..d688ee27a7 100644 --- a/apps/console/src/utils/consoleBase.ts +++ b/apps/console/src/utils/consoleBase.ts @@ -42,3 +42,22 @@ export function withConsoleBase(path: string): string { const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, ''); return base + (path.startsWith('/') ? path : `/${path}`); } + +/** + * A console route as a ROOT-RELATIVE url (`/_console/accept-invitation/ID`) — + * the form a SERVER-side redirect target needs, such as the `callbackURL` + * better-auth writes into the email-verification link (objectui#10893). + * + * `withConsoleBase` is not enough there. In the shipped embeddable build it + * returns a document-relative `./…`, which only a browser resolves (against + * ``); the server never sees that base. better-auth accepts only a + * root-relative path or a trusted absolute URL and answers `./…` with + * `403 INVALID_CALLBACK_URL` — failing the whole sign-up, not just the + * redirect. So this resolves `withConsoleBase`'s answer exactly the way + * `location.assign` would, then keeps the path, query and hash. Every mount + * therefore agrees with where a full-page navigation to the same route lands. + */ +export function withConsoleBaseRootRelative(path: string): string { + const url = new URL(withConsoleBase(path), document.baseURI); + return `${url.pathname}${url.search}${url.hash}`; +} diff --git a/packages/auth/README.md b/packages/auth/README.md index 658c3d49d0..340f6de3ab 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -137,6 +137,26 @@ declare const navigate: (to: string) => void; navigate('/check-email')} />; ``` +When the server requires email verification, a registration ends in the user's +inbox, and the verification link is the only way back. `verificationCallbackURL` +says where that link lands, e.g. the invitation the user signed up from. It is +sent as better-auth's sign-up `callbackURL` (the same value as the fourth +argument of `useAuth().signUp`). The server accepts only a root-relative path +or an absolute URL on a trusted origin, and refuses a document-relative `./…` +with `INVALID_CALLBACK_URL`, which fails the sign-up. Resolve an SPA route +against its mount first. Omitted, the server default (`/`) applies. + +```tsx +import { RegisterForm } from '@object-ui/auth'; + +declare const navigate: (to: string) => void; + + navigate(`/verify-email-prompt?email=${encodeURIComponent(email)}`)} +/>; +``` + ### UserMenu Displays current user info with avatar and sign-out: diff --git a/packages/auth/src/AuthContext.ts b/packages/auth/src/AuthContext.ts index 1a8a92b526..157adbc619 100644 --- a/packages/auth/src/AuthContext.ts +++ b/packages/auth/src/AuthContext.ts @@ -36,8 +36,10 @@ export interface AuthContextValue { /** Sign up with name, email, and password. * Returns `{ requiresVerification: true }` when the server accepted the * account but is gating sign-in on email verification; callers should - * show a "check your inbox" UI instead of navigating to a protected page. */ - signUp: (name: string, email: string, password: string) => Promise<{ requiresVerification: boolean }>; + * show a "check your inbox" UI instead of navigating to a protected page. + * `callbackURL` is where the email-verification link lands — see + * `SignUpData.callbackURL` for the shape the server accepts. */ + signUp: (name: string, email: string, password: string, callbackURL?: string) => Promise<{ requiresVerification: boolean }>; /** Sign out the current user */ signOut: () => Promise; /** diff --git a/packages/auth/src/AuthProvider.tsx b/packages/auth/src/AuthProvider.tsx index 73deee3f3e..1ad0ddd92f 100644 --- a/packages/auth/src/AuthProvider.tsx +++ b/packages/auth/src/AuthProvider.tsx @@ -373,11 +373,11 @@ export function AuthProvider({ ); const signUp = useCallback( - async (name: string, email: string, password: string) => { + async (name: string, email: string, password: string, callbackURL?: string) => { setIsLoading(true); setError(null); try { - const result = await client.signUp({ name, email, password }); + const result = await client.signUp({ name, email, password, ...(callbackURL ? { callbackURL } : {}) }); if (result.requiresVerification) { // No session was created — the user must verify their email before // they can sign in. Leave auth state untouched so the caller can diff --git a/packages/auth/src/RegisterForm.tsx b/packages/auth/src/RegisterForm.tsx index 66e197ca53..8d11143de5 100644 --- a/packages/auth/src/RegisterForm.tsx +++ b/packages/auth/src/RegisterForm.tsx @@ -48,6 +48,16 @@ export interface RegisterFormProps { * sign-in on email verification. The page should swap the form for a * "check your inbox" confirmation. */ onVerificationRequired?: (email: string) => void; + /** + * Where the email-verification link should land once the address is + * verified — e.g. the invitation page the user registered from + * (objectui#10893). Forwarded to `signUp` as better-auth's sign-up + * `callbackURL`; see `SignUpData.callbackURL` for the only shapes the server + * accepts (a document-relative `./…` fails the whole sign-up). Omitted, the + * server default (`/`) applies. Only matters when the server requires email + * verification — an auto-signed-in registration calls `onSuccess` instead. + */ + verificationCallbackURL?: string; /** Callback on registration error */ onError?: (error: Error) => void; /** Link to login page */ @@ -110,6 +120,7 @@ const DefaultUserPlusIcon = () => ( export function RegisterForm({ onSuccess, onVerificationRequired, + verificationCallbackURL, onError, loginUrl = '/login', title = 'Create an account', @@ -163,7 +174,7 @@ export function RegisterForm({ // that the server rejects as "Invalid email" (#3238) — trim before use. const trimmedEmail = email.trim(); try { - const result = await signUp(name, trimmedEmail, password); + const result = await signUp(name, trimmedEmail, password, verificationCallbackURL); if (result?.requiresVerification) { onVerificationRequired?.(trimmedEmail); return; diff --git a/packages/auth/src/__tests__/createAuthClient.test.ts b/packages/auth/src/__tests__/createAuthClient.test.ts index 3562870e74..b7767e93d5 100644 --- a/packages/auth/src/__tests__/createAuthClient.test.ts +++ b/packages/auth/src/__tests__/createAuthClient.test.ts @@ -111,6 +111,38 @@ describe('createAuthClient', () => { expect(result.requiresVerification).toBe(true); }); + // objectui#10893 — the verification mail's link is built server-side from + // the sign-up body's `callbackURL`; a body without one mails `callbackURL=/` + // and the invitee lands on the workspace picker instead of the invitation. + it('signUp sends the verification callbackURL in the /sign-up/email body (objectui#10893)', async () => { + const { mockFn, calls } = createMockFetch({ + '/sign-up/email': { body: { user: { id: '4', name: 'Invitee', email: 'invitee@test.com' }, token: null } }, + }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); + + await client.signUp({ + name: 'Invitee', + email: 'invitee@test.com', + password: 'pass12345', + callbackURL: '/_console/accept-invitation/inv_1', + }); + + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain('/api/auth/sign-up/email'); + expect(JSON.parse(calls[0].body!).callbackURL).toBe('/_console/accept-invitation/inv_1'); + }); + + it('signUp without a callbackURL leaves the key off the wire, so the server default applies (objectui#10893)', async () => { + const { mockFn, calls } = createMockFetch({ + '/sign-up/email': { body: { user: { id: '5', name: 'Plain', email: 'plain@test.com' }, token: null } }, + }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); + + await client.signUp({ name: 'Plain', email: 'plain@test.com', password: 'pass12345' }); + + expect(Object.keys(JSON.parse(calls[0].body!))).not.toContain('callbackURL'); + }); + it('signOut sends POST to /sign-out', async () => { const { mockFn, calls } = createMockFetch({ '/sign-out': { body: { success: true } }, diff --git a/packages/auth/src/__tests__/registerVerificationCallback-10893.test.tsx b/packages/auth/src/__tests__/registerVerificationCallback-10893.test.tsx new file mode 100644 index 0000000000..97a1bacd01 --- /dev/null +++ b/packages/auth/src/__tests__/registerVerificationCallback-10893.test.tsx @@ -0,0 +1,88 @@ +/** + * objectui#10893 — a registration started from an invitation link must carry + * the invitation into the verification mail. + * + * better-auth builds the mail's link server-side as + * `…/verify-email?token=…&callbackURL=VALUE`, taking VALUE from the + * `/sign-up/email` request body and defaulting it to `/`. The register page + * kept `?redirect=/accept-invitation/ID` all the way to the "check your inbox" + * screen, but nothing put it in the body, so the invitee verified, landed on + * `/`, and was offered "Create workspace" instead of the invitation. + * + * This pins the whole in-package seam on the WIRE, not on a mock of the next + * hop: `RegisterForm` → `useAuth().signUp` → `AuthProvider` → the real + * `createAuthClient` → the better-auth client → the request body. A mocked + * `client.signUp` would pass with any one of those links dropping the value. + */ + +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { AuthProvider } from '../AuthProvider'; +import { RegisterForm } from '../RegisterForm'; +import { createAuthClient } from '../createAuthClient'; + +/** Answers `/sign-up/email` as a verification-gated server does (token null). */ +function verificationGatedServer() { + const signUpBodies: Array> = []; + const fetchFn = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url.includes('/sign-up/email')) { + signUpBodies.push(JSON.parse(String(init?.body)) as Record); + return new Response( + JSON.stringify({ user: { id: 'u_new', name: 'Invitee', email: 'invitee@example.com' }, token: null }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + // No session, no config — the provider settles as signed-out. + return new Response(JSON.stringify(null), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + return { fetchFn, signUpBodies }; +} + +async function register(ui: React.ReactElement, fetchFn: typeof fetch) { + const client = createAuthClient({ baseURL: 'http://localhost/api/v1/auth', fetchFn }); + render( + + {ui} + , + ); + fireEvent.change(await screen.findByLabelText('Name'), { target: { value: 'Invitee' } }); + fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'invitee@example.com' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'hunter2hunter2' } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'hunter2hunter2' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create Account' })); +} + +describe('RegisterForm → sign-up wire: the verification callback (objectui#10893)', () => { + it('puts verificationCallbackURL on the /sign-up/email body as callbackURL', async () => { + const { fetchFn, signUpBodies } = verificationGatedServer(); + const onVerificationRequired = vi.fn(); + + await register( + , + fetchFn as unknown as typeof fetch, + ); + + await waitFor(() => expect(onVerificationRequired).toHaveBeenCalledWith('invitee@example.com')); + expect(signUpBodies).toHaveLength(1); + expect(signUpBodies[0].callbackURL).toBe('/_console/accept-invitation/inv_1'); + }); + + it('sends no callbackURL when the form has none, leaving the server default in charge', async () => { + const { fetchFn, signUpBodies } = verificationGatedServer(); + const onVerificationRequired = vi.fn(); + + await register( + , + fetchFn as unknown as typeof fetch, + ); + + await waitFor(() => expect(onVerificationRequired).toHaveBeenCalled()); + expect(signUpBodies).toHaveLength(1); + expect(Object.keys(signUpBodies[0])).not.toContain('callbackURL'); + }); +}); diff --git a/packages/auth/src/createAuthClient.ts b/packages/auth/src/createAuthClient.ts index bec5d9e6ad..24f1a2607f 100644 --- a/packages/auth/src/createAuthClient.ts +++ b/packages/auth/src/createAuthClient.ts @@ -403,6 +403,12 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { email: signUpData.email, password: signUpData.password, name: signUpData.name, + // objectui#10893 — the verification mail's link lands here. Without + // it better-auth writes `callbackURL=/`, and an invitee who registers + // from an invitation link is dropped on the workspace picker instead + // of the invitation. Only sent when the caller has somewhere to go, + // so the server default is untouched otherwise. + ...(signUpData.callbackURL ? { callbackURL: signUpData.callbackURL } : {}), }); if (error) { throw toAuthError(error); diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index 8f14bbfd66..9c39e968bd 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -154,6 +154,19 @@ export interface SignUpData { name: string; email: string; password: string; + /** + * Where the email-verification link lands once the address is verified — + * e.g. the invitation page the user registered from (objectui#10893). + * Sent verbatim as better-auth's `/sign-up/email` `callbackURL`; omitted, + * the server's default (`/`) applies. + * + * better-auth accepts only a root-relative path (`/_console/…`) or an + * absolute URL on a trusted origin. Anything else — including a + * document-relative `./…` — is refused with `INVALID_CALLBACK_URL` and the + * WHOLE sign-up fails, so resolve an SPA route against its mount before + * passing it here. + */ + callbackURL?: string; } /** Auth client configuration */