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 */