Skip to content

Commit 7ea8118

Browse files
hotlongclaude
andauthored
fix(auth): sign-up carries the invitation redirect into the verification mail (objectui#10893) (#10904)
Fixes #10893 Part of objectstack-ai/cloud#2440 Clause-②: no An invitee without an account who follows an invitation link, registers and clicks the verification mail now lands back on the invitation (`/_console/accept-invitation/ID`), not on `/` and the "Create workspace" picker. ## Mechanism, measured better-auth builds the verification link server-side from the `/sign-up/email` body's `callbackURL`, defaulting it to `/`. `createAuthClient().signUp` never sent one. I measured the server half in-process: `betterAuth` with the memory adapter, `requireEmailVerification`, `sendOnSignUp` and `autoSignInAfterVerification`, then a real sign-up and a GET of the mailed link. I ran it against both server versions in play: 1.7.2 (cloud's objectstack pin `bdea10a1`) and 1.7.3 (objectstack `main`). Both gave identical results: | `callbackURL` in the sign-up body | sign-up | `callbackURL` in the mailed link | GET of the link | |---|---|---|---| | absent (today) | 200 | `/` | 302 to `/`, session cookie set | | `/_console/accept-invitation/inv_1` | 200 | `/_console/accept-invitation/inv_1` | 302 to `/_console/accept-invitation/inv_1`, session cookie set | | `./accept-invitation/inv_1` | **403 `INVALID_CALLBACK_URL`** | no mail | n/a | | `/accept-invitation/inv_1` | 200 | `/accept-invitation/inv_1` | 302 to `/accept-invitation/inv_1`: the origin root, **outside** `/_console` | The third row shaped the fix. In the shipped embeddable console build, `withConsoleBase(route)` returns the document-relative `./ROUTE`: only a browser resolves it, against the base-href element, and the server never sees that element. Forwarding it would have made **every** invited registration fail, which is worse than the bug. The `/sign-up/email` response has no redirect/`url` field in either version, so sending `callbackURL` has no client-side navigation effect. ## What changed **`@object-ui/auth`** (additive, `minor`): - `SignUpData.callbackURL` is forwarded verbatim to `/sign-up/email`. When it is absent the key stays off the wire, so the server default is untouched. - `useAuth().signUp(name, email, password, callbackURL?)` takes it as an optional 4th argument (mirrors `sendVerificationEmail(email, callbackURL?)`). - `RegisterForm` gains `verificationCallbackURL`, forwarded to `signUp`. Its doc and `SignUpData.callbackURL`'s doc name the only shapes the server accepts. **`@object-ui/console`** (`patch`): - `RegisterPage` passes a safe `?redirect=` (the same `isSafeRedirect` it already applies) as `verificationCallbackURL`. It passes nothing otherwise. - New `withConsoleBaseRootRelative(route)` in `utils/consoleBase`. It resolves `withConsoleBase`'s answer the way `location.assign` would and keeps the path, query and hash, so every mount gives a root-relative url. It always equals where a full-page navigation to the same route lands. - **Bounded in-place fix, same defect class:** the verify-email prompt's **Resend** sent the bare router path (`redirect || '/'`). Per the fourth row above, that lands at the origin root, outside the mount. It now sends the same value as sign-up, or nothing when there is no safe redirect. All four in-place conditions hold. (1) It is the same class: the verification link misses the console-mounted target. (2) The fix is mechanical, using the same helper. (3) No open PR touches the file (checked across the 4 open PRs; the release PR's file list is capped at 100 entries). (4) It falls under the same console vitest gate family. ⚠️ **File surface beyond the claim.** The claim listed `packages/auth/src/**`, plus `RegisterForm.tsx` "if it owns the redirect". It does not own it: `?redirect=` is owned one level up, by `apps/console/src/pages/auth/RegisterPage.tsx`, which passes it to `RegisterForm` like `loginUrl`. A `packages/auth`-only change could not fix the bug, because no caller would pass the value. Reading `?redirect=` inside the library would be an implicit contract: nothing in `packages/auth` produces or reads that parameter today. The console files touched: `RegisterPage.tsx`, `VerifyEmailPromptPage.tsx`, `utils/consoleBase.ts`, plus tests. ## Tests (all at `3fd6dcd7e`) - **Must-have pin** (`createAuthClient.test.ts`): `signUp sends the verification callbackURL in the /sign-up/email body`, and its negative, "without a callbackURL leaves the key off the wire". - `registerVerificationCallback-10893.test.tsx`: the real `RegisterForm`, real `AuthProvider` and real `createAuthClient`, asserted on the **request body**. - `apps/console/.../verificationCallback-10893.test.tsx`: `RegisterPage` and `VerifyEmailPromptPage` on a real `AuthProvider` and client, asserted on the wire. It covers the three shipped mounts (standalone `/`, embedded `./` plus base href `/_console/`, pinned `/_console/`), no redirect, and an off-site `//evil.example/x` redirect (not forwarded). - `consoleBase.test.ts`: `withConsoleBaseRootRelative` across the three mounts, query and hash, and `/_studio` passthrough. It also pins the hazard: embedded `withConsoleBase` answers `./accept-invitation/inv_1`. **Ablations.** Each ran through `ablation-replace.mjs` (anchor hit 1 to 0, blob moved, restored blob == HEAD, `git diff HEAD` empty): 1. The spread was dropped from `createAuthClient.signUp`. **5 red**: the must-have pin, the form-to-wire pin, and the three console RegisterPage mount cases. The no-callback negatives stayed green. 2. `withConsoleBaseRootRelative` was swapped for `withConsoleBase` in `RegisterPage`. **1 red**: the embedded case received `./accept-invitation/inv_1`. The pinned and standalone cases stayed green, as expected. 3. The pre-fix Resend (`redirect || '/'`) was restored. **2 red**: embedded received `/accept-invitation/inv_1`, and no-redirect received `/` where the key-absent pin expects no key (that second one is byte-level, behaviour-equivalent). 4. Reverse type check: the prop was renamed to `verificationCallbackUrlTypo` in `RegisterPage`, and console `tsc` went red with TS2322 against `RegisterFormProps`. So the console type-check reads the rebuilt `@object-ui/auth` d.ts. For the README snippet, the same typo turned `check:doc-snippets` red, so the new block is judged. ## Gates (exit codes) The `@object-ui/auth` build `0`, type-check `0` (its `tsconfig.test.json` lists both new or edited test files), and test `0` (27 files, 277 tests). `@object-ui/console` type-check `0` (after `turbo build --filter='@object-ui/console^...'`, 34/34) and test `0` (126 files, 1419 tests). Then `check-changeset-presence` `0`, `changeset:check` `0`, `check:changeset-claims` `0` (report-only; it names two pending changesets that mention `packages/auth/src/types.ts` / `README.md`, and both are still true), `check:pending-changeset-literals` `0`, `check:new-line-citations` `0` (0 new), `check:test-path-roots` `0`, `check:readme-exports` `0`, `check:doc-snippets` `0` (679/679 judged), `check:doc-fences` `0`, `check-vi-mock-specifiers` / `-inherit` / `-override-shape` `0`, `check-control-bytes` `0`, `check-shell-escape-residue` `0`, and `check-governed-queue-guard --test` NOT GOVERNED. Lint: `eslint --no-inline-config` on the 12 changed source files gave 0 errors and 27 warnings, identical to the same 10 pre-existing files at base (27), with 0 in the new files. That is a narrowed run, not the full `pnpm lint`. `eslint.config.js` enables no type-aware linting (no `projectService`/`parserOptions`), so this diff cannot move an untouched file's verdict. The full `pnpm lint` is left to CI. The verify lock ran in **declared UNLOCKED mode**: the host is macOS with no usable `flock`, so nothing was serialized. ## Acceptance notes - **End to end NOT MEASURED.** I did not run a live invite → register → verify → click stack, local or cloud. The server half is the in-process probe above, and the client half is the wire tests. Cloud consumes objectui through `.objectui-sha` (`efead6c6`, far behind `main`), so this reaches cloud only with a later objectui pin bump. - After verification the invitee lands on `/_console/accept-invitation/ID`. With `autoSignInAfterVerification` the probe shows the session cookie is set on that 302. Without it, `AcceptInvitationPage` already bounces to `/login?redirect=…` and back. I have not verified cloud's setting. - **Nice-to-have, not implemented (not in `packages/auth`):** - The "you were invited" copy on login/register would live in `apps/console/src/pages/auth/LoginPage.tsx` / `RegisterPage.tsx`. The organization name cannot be read before sign-in: better-auth's `/organization/get-invitation` answers `UNAUTHORIZED` without a session, and for a non-recipient `YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION`. So "invited to X" would need a server-side public read or the name carried in the link. Generic copy keyed on a `/accept-invitation/` redirect would be console-only. - The pending-invitation hint on the workspace screen would live in `packages/app-shell/src/console/organizations/OrganizationsPage.tsx`. `useAuth().listUserInvitations()` already exists, and nothing in app-shell or console reads it today. - **Sibling, not fixed:** if a deployment sets `emailVerification.sendOnSignIn`, a sign-in by an unverified user mails a link built from the sign-in body's `callbackURL`, which `LoginForm` never sends. That is `/` again. Fixing it is not a one-liner, because on `/sign-in/email` a `callbackURL` also makes the response carry `redirect: true` + `url`, so it is a different design question. Whether cloud sets `sendOnSignIn` is not measured. - `packages/app-shell`'s `DefaultRegisterPage` has no `?redirect=` handling at all. It has no consumer in this repo; the console routes `/register` to its own `RegisterPage`. - PM hypothesis note: the dispatch's comparison point in `LoginForm` (the `base + '/home'` `callbackURL`) is the **SSO** sign-in, not resend. It derives the base from `location.pathname`. The resend path is `VerifyEmailPromptPage` (fixed above). Dispatched from PM session `786273a0-0246-4bb2-b026-bb37ba5d7295` (epic objectstack-ai/cloud#2440). Related: objectstack-ai/cloud#2429 (the other half of the invite chain), objectstack-ai/objectstack#20374 (the plain-text `&amp;` in the verification mail). --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f0c84a commit 7ea8118

14 files changed

Lines changed: 497 additions & 9 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@object-ui/auth': minor
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(auth, console): a registration started from an invitation link comes back to the invitation after email verification
7+
8+
better-auth builds the verification mail's link server-side from the
9+
`/sign-up/email` request's `callbackURL`, defaulting it to `/`. The console kept
10+
`?redirect=/accept-invitation/ID` from the login page through `/register` to the
11+
"check your inbox" screen, but `createAuthClient().signUp` never sent a
12+
`callbackURL`, so the mail read `callbackURL=/` and the invitee verified onto the
13+
workspace picker ("Create workspace") instead of the invitation.
14+
15+
`@object-ui/auth` (additive):
16+
17+
- `SignUpData.callbackURL` is forwarded verbatim to `/sign-up/email`. Absent, the
18+
key stays off the wire and the server default is untouched.
19+
- `useAuth().signUp(name, email, password, callbackURL?)` takes it as an optional
20+
fourth argument.
21+
- `RegisterForm` gains a `verificationCallbackURL` prop, forwarded to `signUp`.
22+
23+
`@object-ui/console`:
24+
25+
- `RegisterPage` passes a safe `?redirect=` as the verification callback, and the
26+
verify-email prompt's "Resend" sends the same value. Before, Resend sent the
27+
bare router path, which the server redirects to at the ORIGIN root, outside
28+
the `/_console` mount.
29+
- Both resolve the route through the new `withConsoleBaseRootRelative`. The server
30+
never sees `<base href>`, and better-auth refuses a document-relative `./…`
31+
callback — what `withConsoleBase` answers in the embedded build — with
32+
`403 INVALID_CALLBACK_URL`, failing the whole sign-up.

‎apps/console/src/pages/auth/RegisterPage.tsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
* - Bounces to `/login` if `emailPassword.disableSignUp === true`
99
* (defense-in-depth; the server-side gate is the source of truth).
1010
* - Routes to `/verify-email-prompt` when the server requires email
11-
* verification before sign-in.
11+
* verification before sign-in, and carries `?redirect=` into the
12+
* verification mail's link so it survives the inbox (objectui#10893).
1213
* - Replays an `/oauth2/authorize` query string when the user landed
1314
* here mid-SSO so the IdP can continue the flow post-signup.
1415
*/
@@ -22,7 +23,7 @@ import { AuthLayout } from './AuthLayout';
2223
import { followOauthAuthorize } from './followAuthorize';
2324
// Was a second module-private copy of LoginPage's helper; both now share one
2425
// implementation — objectui#4181. Behaviour here is unchanged.
25-
import { withConsoleBase } from '../../utils/consoleBase';
26+
import { withConsoleBase, withConsoleBaseRootRelative } from '../../utils/consoleBase';
2627

2728
function isSafeRedirect(target: string | null): target is string {
2829
return !!target && target.startsWith('/') && !target.startsWith('//');
@@ -141,6 +142,13 @@ export function RegisterPage() {
141142
}
142143

143144
const loginUrl = redirect ? `/login?redirect=${encodeURIComponent(redirect)}` : '/login';
145+
// objectui#10893 — when the server gates sign-in on email verification, the
146+
// mail's link is the only road back, so it must carry `?redirect=` (e.g. the
147+
// invitation the user registered from). Only a safe in-app target is
148+
// forwarded; anything else would be refused by the server and fail sign-up.
149+
const verificationCallbackURL = isSafeRedirect(redirect)
150+
? withConsoleBaseRootRelative(redirect)
151+
: undefined;
144152

145153
return (
146154
<AuthLayout formWidth="md">
@@ -151,6 +159,7 @@ export function RegisterPage() {
151159
defaultValue: 'Create your account to start building.',
152160
})}
153161
loginUrl={loginUrl}
162+
verificationCallbackURL={verificationCallbackURL}
154163
linkComponent={RouterLink}
155164
errorMessages={{
156165
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: t('auth.register.errors.userExists', {

‎apps/console/src/pages/auth/VerifyEmailPromptPage.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
CardTitle,
2222
} from '@object-ui/components';
2323
import { AuthLayout } from './AuthLayout';
24+
import { withConsoleBaseRootRelative } from '../../utils/consoleBase';
2425

2526
export function VerifyEmailPromptPage() {
2627
const { t } = useObjectTranslation();
@@ -56,7 +57,16 @@ export function VerifyEmailPromptPage() {
5657
setResending(true);
5758
setResendError(null);
5859
try {
59-
await sendVerificationEmail(email, redirect || '/');
60+
// objectui#10893 — `redirect` is a router path (basename-stripped). The
61+
// server redirects to this value verbatim after verification, so it
62+
// must be resolved against the console mount first: the bare route
63+
// landed at the ORIGIN root, outside `/_console`. No safe target means
64+
// no callbackURL, and the server default applies, as it did before.
65+
const callbackURL =
66+
redirect.startsWith('/') && !redirect.startsWith('//')
67+
? withConsoleBaseRootRelative(redirect)
68+
: undefined;
69+
await sendVerificationEmail(email, callbackURL);
6070
setResent(true);
6171
toast.success(
6272
t('auth.verifyEmail.resentSuccess', {
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* objectui#10893 — an invitee who registers from an invitation link must be
3+
* brought back to the invitation by the verification mail.
4+
*
5+
* The console kept `?redirect=/accept-invitation/ID` from the login page through
6+
* `/register` to `/verify-email-prompt`, but the verification mail is built
7+
* server-side from the sign-up request's `callbackURL`, which nothing sent: the
8+
* link read `callbackURL=/` and the invitee verified onto the workspace picker.
9+
*
10+
* Two console seams feed that value:
11+
*
12+
* - `RegisterPage` → `RegisterForm.verificationCallbackURL` → `signUp`'s 4th
13+
* argument (the first mail);
14+
* - `VerifyEmailPromptPage` → `sendVerificationEmail`'s 2nd argument (the
15+
* "Resend" mail), which forwarded the bare router path and so pointed at the
16+
* ORIGIN root, outside the console mount.
17+
*
18+
* Both must hand the server a ROOT-relative url inside the mount. The server
19+
* never sees `<base href>`; better-auth refuses a document-relative `./…` with
20+
* `403 INVALID_CALLBACK_URL`, failing the whole sign-up. So the embedded mount
21+
* (whose `withConsoleBase` answer is `./…`) is the case that matters most.
22+
*
23+
* Nothing in `@object-ui/auth` is replaced: a real `AuthProvider` over a real
24+
* `createAuthClient` runs against a stub server, and every assertion reads the
25+
* REQUEST BODY the server would receive — the value better-auth writes into the
26+
* mail's link. So the page, the shipped `RegisterForm`, `useAuth`, the provider
27+
* and the client are all on the path; dropping the value at any hop turns these
28+
* red.
29+
*/
30+
31+
import '@testing-library/jest-dom/vitest';
32+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
33+
import { render, screen, cleanup, waitFor } from '@testing-library/react';
34+
import userEvent from '@testing-library/user-event';
35+
import { BrowserRouter } from 'react-router-dom';
36+
import { I18nProvider } from '@object-ui/i18n';
37+
import { AuthProvider, createAuthClient } from '@object-ui/auth';
38+
39+
vi.mock('sonner', async (importOriginal) => {
40+
const actual = await importOriginal<Record<string, unknown>>();
41+
return { ...actual, toast: { success: vi.fn(), error: vi.fn() } };
42+
});
43+
44+
const { RegisterPage } = await import('../RegisterPage');
45+
const { VerifyEmailPromptPage } = await import('../VerifyEmailPromptPage');
46+
47+
/** The three configurations the console ships in (see authExitBasename.test). */
48+
const MOUNTS = {
49+
standalone: { href: null, baseUrl: '/', basename: '/', prefix: '' },
50+
embedded: { href: '/_console/', baseUrl: './', basename: '/_console', prefix: '/_console' },
51+
pinned: { href: '/_console/', baseUrl: '/_console/', basename: '/_console', prefix: '/_console' },
52+
} as const;
53+
type MountName = keyof typeof MOUNTS;
54+
55+
let baseEl: HTMLBaseElement | null = null;
56+
57+
function mountConsole(name: MountName, at: string) {
58+
const mount = MOUNTS[name];
59+
baseEl?.remove();
60+
baseEl = null;
61+
if (mount.href) {
62+
baseEl = document.createElement('base');
63+
baseEl.setAttribute('href', mount.href);
64+
document.head.appendChild(baseEl);
65+
}
66+
vi.stubEnv('BASE_URL', mount.baseUrl);
67+
window.history.replaceState({}, '', `${mount.prefix}${at}`);
68+
return mount;
69+
}
70+
71+
const AUTH_URL = 'http://localhost/api/v1/auth';
72+
type WireBodies = Record<'signUp' | 'resend', Array<Record<string, unknown>>>;
73+
let wire: WireBodies;
74+
75+
/**
76+
* A signed-out visitor on a server that gates sign-in on email verification:
77+
* no session, `/sign-up/email` answers with a null token.
78+
*/
79+
function verificationGatedServer(): typeof fetch {
80+
wire = { signUp: [], resend: [] };
81+
const json = (body: unknown) =>
82+
new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
83+
return (async (input: string | URL | Request, init?: RequestInit) => {
84+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
85+
if (url.includes('/sign-up/email')) {
86+
wire.signUp.push(JSON.parse(String(init?.body)));
87+
return json({ user: { id: 'u_new', name: 'Wang Wei', email: 'wangwei@example.com' }, token: null });
88+
}
89+
if (url.includes('/send-verification-email')) {
90+
wire.resend.push(JSON.parse(String(init?.body)));
91+
return json({ status: true });
92+
}
93+
if (url.endsWith('/config')) return json({});
94+
return json(null);
95+
}) as typeof fetch;
96+
}
97+
98+
function renderAt(basename: string, ui: React.ReactElement) {
99+
const client = createAuthClient({ baseURL: AUTH_URL, fetchFn: verificationGatedServer() });
100+
return render(
101+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
102+
<AuthProvider authUrl={AUTH_URL} client={client}>
103+
<BrowserRouter basename={basename}>{ui}</BrowserRouter>
104+
</AuthProvider>
105+
</I18nProvider>,
106+
);
107+
}
108+
109+
/** What the server would be asked to redirect to after verification. */
110+
async function signUpCallback(name: MountName, search: string): Promise<unknown> {
111+
const mount = mountConsole(name, `/register${search}`);
112+
renderAt(mount.basename, <RegisterPage />);
113+
114+
await userEvent.type(await screen.findByLabelText('Name'), 'Wang Wei');
115+
await userEvent.type(screen.getByLabelText('Email'), 'wangwei@example.com');
116+
await userEvent.type(screen.getByLabelText('Password'), 'hunter2hunter2');
117+
await userEvent.type(screen.getByLabelText('Confirm Password'), 'hunter2hunter2');
118+
await userEvent.click(screen.getByRole('button', { name: 'Create Account' }));
119+
120+
await waitFor(() => expect(wire.signUp).toHaveLength(1));
121+
expect(wire.signUp[0]).toMatchObject({ name: 'Wang Wei', email: 'wangwei@example.com' });
122+
return wire.signUp[0].callbackURL;
123+
}
124+
125+
/** A url better-auth accepts as a callbackURL without a trusted-origin list. */
126+
function isRootRelative(value: unknown): boolean {
127+
return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//');
128+
}
129+
130+
const INVITE = '?redirect=%2Faccept-invitation%2Finv_1';
131+
132+
beforeEach(() => {
133+
vi.spyOn(window.location, 'assign').mockImplementation(() => undefined);
134+
vi.spyOn(console, 'warn').mockImplementation(() => {});
135+
});
136+
137+
afterEach(() => {
138+
cleanup();
139+
baseEl?.remove();
140+
baseEl = null;
141+
vi.unstubAllEnvs();
142+
vi.restoreAllMocks();
143+
});
144+
145+
describe('RegisterPage — the verification mail carries ?redirect= (objectui#10893)', () => {
146+
it('embedded console: the invitation, root-relative inside the mount', async () => {
147+
const callback = await signUpCallback('embedded', INVITE);
148+
expect(callback).toBe('/_console/accept-invitation/inv_1');
149+
expect(isRootRelative(callback)).toBe(true);
150+
});
151+
152+
it('pinned-base console: the invitation, inside the mount', async () => {
153+
expect(await signUpCallback('pinned', INVITE)).toBe('/_console/accept-invitation/inv_1');
154+
});
155+
156+
it('default `/` mount: the invitation route as-is', async () => {
157+
expect(await signUpCallback('standalone', INVITE)).toBe('/accept-invitation/inv_1');
158+
});
159+
160+
it('no ?redirect=: no callbackURL on the wire, so the server default is untouched', async () => {
161+
expect(await signUpCallback('embedded', '')).toBeUndefined();
162+
expect(Object.keys(wire.signUp[0])).not.toContain('callbackURL');
163+
});
164+
165+
it('an off-site ?redirect= is not forwarded (the server would refuse it and fail sign-up)', async () => {
166+
expect(await signUpCallback('embedded', '?redirect=%2F%2Fevil.example%2Fx')).toBeUndefined();
167+
});
168+
169+
it('still routes to the inbox prompt with the redirect kept', async () => {
170+
await signUpCallback('embedded', INVITE);
171+
await waitFor(() => expect(window.location.pathname).toBe('/_console/verify-email-prompt'));
172+
const sp = new URLSearchParams(window.location.search);
173+
expect(sp.get('email')).toBe('wangwei@example.com');
174+
expect(sp.get('redirect')).toBe('/accept-invitation/inv_1');
175+
});
176+
});
177+
178+
describe('VerifyEmailPromptPage — the resent mail carries ?redirect= too (objectui#10893)', () => {
179+
async function resendCallback(name: MountName, search: string): Promise<[unknown, unknown]> {
180+
const mount = mountConsole(name, `/verify-email-prompt${search}`);
181+
renderAt(mount.basename, <VerifyEmailPromptPage />);
182+
await userEvent.click(await screen.findByRole('button', { name: /Resend verification email/ }));
183+
await waitFor(() => expect(wire.resend).toHaveLength(1));
184+
return [wire.resend[0].email, wire.resend[0].callbackURL];
185+
}
186+
187+
it('embedded console: resolves the router path into the mount', async () => {
188+
const [email, callback] = await resendCallback('embedded', `?email=wangwei%40example.com&${INVITE.slice(1)}`);
189+
expect(email).toBe('wangwei@example.com');
190+
// The pre-fix value was the bare router path '/accept-invitation/inv_1',
191+
// which the server redirected to at the ORIGIN root, outside `/_console`.
192+
expect(callback).toBe('/_console/accept-invitation/inv_1');
193+
});
194+
195+
it('default `/` mount: the invitation route as-is', async () => {
196+
const [, callback] = await resendCallback('standalone', `?email=wangwei%40example.com&${INVITE.slice(1)}`);
197+
expect(callback).toBe('/accept-invitation/inv_1');
198+
});
199+
200+
it('no ?redirect=: no callbackURL on the wire, so the server default applies', async () => {
201+
const [, callback] = await resendCallback('embedded', '?email=wangwei%40example.com');
202+
expect(callback).toBeUndefined();
203+
expect(Object.keys(wire.resend[0])).not.toContain('callbackURL');
204+
});
205+
});

‎apps/console/src/utils/consoleBase.test.ts‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
*/
2020

2121
import { describe, it, expect, vi, afterEach } from 'vitest';
22-
import { withConsoleBase } from './consoleBase';
22+
import { withConsoleBase, withConsoleBaseRootRelative } from './consoleBase';
2323

2424
let baseEl: HTMLBaseElement | null = null;
2525

@@ -108,3 +108,44 @@ describe('withConsoleBase', () => {
108108
expect(withConsoleBase('organizations')).toBe('/_console/organizations');
109109
});
110110
});
111+
112+
/**
113+
* `withConsoleBaseRootRelative` — the same route as a server-side redirect
114+
* target (objectui#10893). The server never sees `<base href>`, and
115+
* better-auth refuses a document-relative `./…` callbackURL with
116+
* `403 INVALID_CALLBACK_URL` (failing the whole sign-up), so every mount must
117+
* yield a ROOT-relative path — and it must be the path a full-page navigation
118+
* to the same route lands on.
119+
*/
120+
describe('withConsoleBaseRootRelative', () => {
121+
const ROUTE = '/accept-invitation/inv_1';
122+
123+
it('THE HAZARD it exists for: the embedded build\'s withConsoleBase answer is document-relative', () => {
124+
mountConsole('/_console/', './');
125+
expect(withConsoleBase(ROUTE)).toBe('./accept-invitation/inv_1');
126+
});
127+
128+
it.each([
129+
['the shipped embeddable build', '/_console/', './', '/_console/accept-invitation/inv_1'],
130+
['a pinned absolute base', '/_console/', '/_console/', '/_console/accept-invitation/inv_1'],
131+
['the default `/` mount', null, '/', '/accept-invitation/inv_1'],
132+
])('%s: root-relative, and where a navigation to the route lands', (_name, href, baseUrl, expected) => {
133+
mountConsole(href, baseUrl);
134+
const target = withConsoleBaseRootRelative(ROUTE);
135+
expect(target).toBe(expected);
136+
expect(target.startsWith('/') && !target.startsWith('//')).toBe(true);
137+
expect(target).toBe(lands(withConsoleBase(ROUTE)));
138+
});
139+
140+
it('keeps the query and hash of the route', () => {
141+
mountConsole('/_console/', './');
142+
expect(withConsoleBaseRootRelative('/settings?tab=members#invites')).toBe(
143+
'/_console/settings?tab=members#invites',
144+
);
145+
});
146+
147+
it('leaves a target that names its own absolute SPA mount untouched', () => {
148+
mountConsole('/_console/', './');
149+
expect(withConsoleBaseRootRelative('/_studio/apps')).toBe('/_studio/apps');
150+
});
151+
});

‎apps/console/src/utils/consoleBase.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,22 @@ export function withConsoleBase(path: string): string {
4242
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
4343
return base + (path.startsWith('/') ? path : `/${path}`);
4444
}
45+
46+
/**
47+
* A console route as a ROOT-RELATIVE url (`/_console/accept-invitation/ID`) —
48+
* the form a SERVER-side redirect target needs, such as the `callbackURL`
49+
* better-auth writes into the email-verification link (objectui#10893).
50+
*
51+
* `withConsoleBase` is not enough there. In the shipped embeddable build it
52+
* returns a document-relative `./…`, which only a browser resolves (against
53+
* `<base href>`); the server never sees that base. better-auth accepts only a
54+
* root-relative path or a trusted absolute URL and answers `./…` with
55+
* `403 INVALID_CALLBACK_URL` — failing the whole sign-up, not just the
56+
* redirect. So this resolves `withConsoleBase`'s answer exactly the way
57+
* `location.assign` would, then keeps the path, query and hash. Every mount
58+
* therefore agrees with where a full-page navigation to the same route lands.
59+
*/
60+
export function withConsoleBaseRootRelative(path: string): string {
61+
const url = new URL(withConsoleBase(path), document.baseURI);
62+
return `${url.pathname}${url.search}${url.hash}`;
63+
}

0 commit comments

Comments
 (0)