From ccfb7ef9eb40b654b0e15dad81ed22a0e4d4f27d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 18:41:38 +0000 Subject: [PATCH] fix(console): the first-run setup exits land inside the console mount (#4181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SetupPage finished the first-run owner bootstrap with window.location.assign('/') at both of its exits — the success path after signUp() plus the bootstrap-org rename, and the already-signed-in bounce. location.assign bypasses React Router's basename, so on a console served under an injected `base href` (the framework CLI injects one for every embedded deployment) a root-relative '/' resolves to the ORIGIN root and drops a brand-new owner outside the SPA, on the first screen after creating their account. Both exits now route through withConsoleBase(). They stay FULL-PAGE navigations on purpose: ConsoleShell mounts MetadataProvider once auth resolves rather than once it authenticates (objectui#4042) and re-keys it on `language` alone, so the app list read while nobody was signed in would survive a router navigation and land the new owner in an appless console. withConsoleBase was module-private to LoginPage and had ALREADY been copied verbatim into RegisterPage, so the lift covers three call sites rather than the two the card assumed. LoginPage/RegisterPage behaviour is unchanged and pinned as unchanged across all three mount configurations. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .../setup-exit-console-basename-4181.md | 13 + apps/console/src/pages/auth/LoginPage.tsx | 19 +- apps/console/src/pages/auth/RegisterPage.tsx | 11 +- apps/console/src/pages/auth/SetupPage.tsx | 32 +- .../auth/__tests__/authExitBasename.test.tsx | 325 ++++++++++++++++++ apps/console/src/utils/consoleBase.test.ts | 110 ++++++ apps/console/src/utils/consoleBase.ts | 44 +++ 7 files changed, 529 insertions(+), 25 deletions(-) create mode 100644 .changeset/setup-exit-console-basename-4181.md create mode 100644 apps/console/src/pages/auth/__tests__/authExitBasename.test.tsx create mode 100644 apps/console/src/utils/consoleBase.test.ts create mode 100644 apps/console/src/utils/consoleBase.ts diff --git a/.changeset/setup-exit-console-basename-4181.md b/.changeset/setup-exit-console-basename-4181.md new file mode 100644 index 0000000000..1ead01b50f --- /dev/null +++ b/.changeset/setup-exit-console-basename-4181.md @@ -0,0 +1,13 @@ +--- +'@object-ui/console': patch +--- + +The first-run setup wizard no longer drops a brand-new owner outside the console + +On a console served under a mount — `/_console/`, which the framework CLI configures for every embedded deployment by injecting a `` — finishing the first-run owner bootstrap landed the new owner on the ORIGIN root instead of the console. Both of `SetupPage`'s exits navigated to a bare `/`: the success path after the account is created and the bootstrap organization renamed, and the bounce that sends an already-signed-in visitor away. `window.location.assign` does not go through React Router, so its `basename` never applied and a root-relative `/` left the SPA. It is the worst possible moment for a dead end — the first screen after creating the account, on a deployment that by definition has no other account to recover with. + +Under the default `/` mount the prefixed and unprefixed spellings are identical, which is why no standalone `os dev` run ever surfaced this. + +Both exits now go through the console-mount helper `LoginPage` already used for exactly this, so they land inside the SPA under every mount. They stay full-page navigations deliberately: the console shell mounts its metadata tree as soon as auth *resolves* rather than when it authenticates, and re-keys it only on language, so the app list read while nobody was signed in would survive a router navigation and leave the new owner in an appless console. Tearing the document down is what guarantees the console rebuilds with the session. + +The helper itself was module-private to `LoginPage` and had already been copied verbatim into `RegisterPage`. It now lives in one place with all three auth surfaces importing it, so the next mount fix lands once rather than three times. `LoginPage` and `RegisterPage` behaviour is unchanged, and pinned as unchanged across all three mount configurations. diff --git a/apps/console/src/pages/auth/LoginPage.tsx b/apps/console/src/pages/auth/LoginPage.tsx index f44d513d8b..8616344049 100644 --- a/apps/console/src/pages/auth/LoginPage.tsx +++ b/apps/console/src/pages/auth/LoginPage.tsx @@ -23,27 +23,16 @@ import { useObjectTranslation } from '@object-ui/i18n'; import { Card } from '@object-ui/components'; import { AuthLayout } from './AuthLayout'; import { followOauthAuthorize } from './followAuthorize'; +// Was module-private here; lifted to a shared module so `SetupPage` (whose +// first-run exits went without it) and `RegisterPage` (which had copied it) +// share ONE implementation — objectui#4181. Behaviour here is unchanged. +import { withConsoleBase } from '../../utils/consoleBase'; /** Restrict the post-login redirect to same-origin paths. */ function isSafeRedirect(target: string | null): target is string { return !!target && target.startsWith('/') && !target.startsWith('//'); } -/** - * Prefix a router-relative path with the Console basename for full-page - * navigations. `window.location.assign` bypasses React Router's `basename`, - * so a path produced by the router (e.g. `?redirect=/settings` — already - * basename-stripped) or a literal like `/organizations` would resolve to - * `http://host/settings`, missing the `/_console` mount and 404-ing. - * Paths already targeting another absolute SPA mount (`/_studio`, - * `/_account`, …) pass through untouched. - */ -function withConsoleBase(path: string): string { - if (path.startsWith('/_')) return path; - const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, ''); - return base + (path.startsWith('/') ? path : `/${path}`); -} - const DEV_HINT_DISMISSED_KEY = 'os.console.devAdminHintDismissed'; function RouterLink(props: { href: string; className?: string; children: React.ReactNode }) { diff --git a/apps/console/src/pages/auth/RegisterPage.tsx b/apps/console/src/pages/auth/RegisterPage.tsx index 3b9cf94f9d..a41e6a9bb8 100644 --- a/apps/console/src/pages/auth/RegisterPage.tsx +++ b/apps/console/src/pages/auth/RegisterPage.tsx @@ -20,19 +20,14 @@ import { useObjectTranslation } from '@object-ui/i18n'; import { Card } from '@object-ui/components'; 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'; function isSafeRedirect(target: string | null): target is string { return !!target && target.startsWith('/') && !target.startsWith('//'); } -/** Prefix a router-relative path with the Console basename for full-page - * navigations (see LoginPage for the detailed rationale). */ -function withConsoleBase(path: string): string { - if (path.startsWith('/_')) return path; - const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, ''); - return base + (path.startsWith('/') ? path : `/${path}`); -} - function RouterLink(props: { href: string; className?: string; children: React.ReactNode }) { return ( diff --git a/apps/console/src/pages/auth/SetupPage.tsx b/apps/console/src/pages/auth/SetupPage.tsx index e64f41d033..ee8e44ae1d 100644 --- a/apps/console/src/pages/auth/SetupPage.tsx +++ b/apps/console/src/pages/auth/SetupPage.tsx @@ -24,9 +24,37 @@ import { Input, Label, } from '@object-ui/components'; +import { withConsoleBase } from '../../utils/consoleBase'; const AUTH_BASE = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`; +/** + * Both exits below are FULL-PAGE navigations, and they have to stay that way — + * `withConsoleBase` fixes where they land, not what kind of navigation they are + * (objectui#4181). + * + * A router `navigate('/')` would keep the SPA alive, and the console's shell is + * not built to survive an anonymous → owner transition in place: + * `ConsoleShell.ConnectedShellInner` mounts `MetadataProvider` once auth merely + * RESOLVES (it deliberately does not gate on `isAuthenticated`, objectui#4042) + * and keys it on `language` alone. So on a first-run deployment the metadata + * tree is already mounted, populated by reads that ran with no session, and + * nothing in its effect deps changes when `signUp()` creates one. The landing + * resolution the exit hands off to (`RootLandingRedirect.resolveLandingPath`) + * reads that app list — which would still be the empty anonymous-era one, so a + * router navigation drops the new owner into an appless console. + * + * The permission grant makes it worse: the bootstrap runs off a permission-grant + * middleware that "may land moments after signUp() resolves" (see handleSubmit), + * so even a re-fetch raced at exit time is not reliably the owner's world. + * + * Tearing the document down is what guarantees the console rebuilds with the + * session cookie present. It is also what the two sibling auth surfaces already + * do for the same reason — `LoginPage` and `RegisterPage` both exit through + * `window.location.assign(withConsoleBase(…))`. + */ +const POST_BOOTSTRAP_EXIT = '/'; + function slugify(input: string): string { return input .toLowerCase() @@ -87,7 +115,7 @@ export function SetupPage() { // navigating here killed that in-flight rename (the org silently kept the // "Default Organization" name). handleSubmit owns the redirect on success. if (user && !submitting) { - window.location.assign('/'); + window.location.assign(withConsoleBase(POST_BOOTSTRAP_EXIT)); } }, [user, submitting]); @@ -146,7 +174,7 @@ export function SetupPage() { } } - window.location.assign('/'); + window.location.assign(withConsoleBase(POST_BOOTSTRAP_EXIT)); } catch (err) { toast.error( t('auth.setup.failed', { defaultValue: 'Setup failed' }), diff --git a/apps/console/src/pages/auth/__tests__/authExitBasename.test.tsx b/apps/console/src/pages/auth/__tests__/authExitBasename.test.tsx new file mode 100644 index 0000000000..43dbb6d9ec --- /dev/null +++ b/apps/console/src/pages/auth/__tests__/authExitBasename.test.tsx @@ -0,0 +1,325 @@ +/** + * The console's full-page auth exits land INSIDE the SPA mount (objectui#4181). + * + * ## What was broken + * + * `SetupPage` finishes the first-run owner bootstrap with a full-page + * navigation, twice — the already-signed-in bounce and the success path after + * `signUp()` + the bootstrap-org rename. Both went to a bare `'/'`. + * `window.location.assign` bypasses React Router's `basename`, so on a console + * served under an injected `` (the framework CLI injects + * one for every embedded deployment) a root-relative `'/'` resolves to the + * ORIGIN root and drops the brand-new owner outside the SPA — on the first + * screen after creating their account. + * + * `LoginPage` already carried the named fix as a module-private helper. This + * change lifts it to `utils/consoleBase` and points all three auth surfaces at + * it, so the pins below come in two flavours: + * + * - **SetupPage** — the fix. Red on the pre-fix source. + * - **LoginPage / RegisterPage** — the LIFT is behaviour-neutral. These pin + * the existing redirects across the same mounts so "pure lift" is a measured + * claim rather than an assertion. (`RegisterPage` had its own byte-identical + * copy of the helper, so the lift covered three call sites, not the two the + * card assumed.) + * + * ## How landing is asserted + * + * Not by string equality against the assign argument — that would only restate + * the implementation, and it cannot express the shipped embeddable build, where + * the correct target is the RELATIVE `'./'` (see `utils/consoleBase.test.ts`). + * Each assertion resolves the assign target against the document's base URL, + * which is the resolution `location.assign` itself performs, and asks whether + * the result is inside the mount. + * + * The mount is configured the way a real deployment configures it: an injected + * `` plus the `BASE_URL` Vite baked into that build. + */ + +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'; + +/** Only `useAuth` is replaced — `LoginForm`/`RegisterForm` stay real. */ +let authState: Record; +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => authState, +})); + +vi.mock('sonner', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, toast: { success: vi.fn(), error: vi.fn() } }; +}); + +const { SetupPage } = await import('../SetupPage'); +const { LoginPage } = await import('../LoginPage'); +const { RegisterPage } = await import('../RegisterPage'); + +// --------------------------------------------------------------------------- +// Mount harness +// --------------------------------------------------------------------------- + +/** + * The three configurations the console ships in. `baseUrl` is what Vite bakes + * into `import.meta.env.BASE_URL`; `href` is what the framework CLI injects. + */ +const MOUNTS = { + /** `os dev` / a bare standalone deployment. The bug is invisible here. */ + standalone: { href: null, baseUrl: '/', basename: '/', prefix: '' }, + /** The shipped embeddable build (`vite.config.ts` `base: './'`). */ + embedded: { href: '/_console/', baseUrl: './', basename: '/_console', prefix: '/_console' }, + /** A demo build pinned with `VITE_BASE_PATH=/_console/`. */ + pinned: { href: '/_console/', baseUrl: '/_console/', basename: '/_console', prefix: '/_console' }, +} as const; + +type MountName = keyof typeof MOUNTS; + +let baseEl: HTMLBaseElement | null = null; +let assign: ReturnType; + +function mountConsole(name: MountName, at = '/'): (typeof MOUNTS)[MountName] { + 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; +} + +/** The single full-page navigation this render performed. */ +async function exitTarget(): Promise { + await waitFor(() => expect(assign).toHaveBeenCalled()); + return assign.mock.calls[0][0] as string; +} + +/** Where that navigation actually lands, per the browser's own resolution. */ +function lands(target: string): string { + return new URL(target, document.baseURI).pathname; +} + +beforeEach(() => { + assign = vi.fn(); + vi.spyOn(window.location, 'assign').mockImplementation(assign as never); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + baseEl?.remove(); + baseEl = null; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +function renderAt(basename: string, ui: React.ReactElement) { + return render( + + {ui} + , + ); +} + +// --------------------------------------------------------------------------- +// SetupPage — THE FIX +// --------------------------------------------------------------------------- + +/** `hasOwner` decides whether the wizard renders or bounces. */ +function stubBootstrapStatus(hasOwner: boolean) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, json: async () => ({ hasOwner }) })), + ); +} + +function setupAuth(overrides: Record = {}) { + authState = { + user: null, + signUp: vi.fn(async () => undefined), + refreshOrganizations: vi.fn(async () => [{ id: 'org_1' }]), + updateOrganization: vi.fn(async () => undefined), + createOrganization: vi.fn(async () => ({ id: 'org_1' })), + switchOrganization: vi.fn(async () => undefined), + ...overrides, + }; +} + +describe('SetupPage — the first-run owner bootstrap exits into the SPA', () => { + describe('the success path (after signUp + the bootstrap-org rename)', () => { + async function completeTheWizard(name: MountName) { + const mount = mountConsole(name, '/setup'); + stubBootstrapStatus(false); + setupAuth(); + renderAt(mount.basename, ); + + // The wizard only renders once the bootstrap probe answers. + const nameField = await screen.findByLabelText('Your name'); + await userEvent.type(nameField, 'Ada'); + await userEvent.type(screen.getByLabelText('Organization name'), 'Acme Inc.'); + await userEvent.type(screen.getByLabelText('Email'), 'ada@example.com'); + await userEvent.type(screen.getByLabelText('Password'), 'hunter2hunter2'); + await userEvent.click(screen.getByRole('button', { name: 'Create owner account' })); + return mount; + } + + it('THE FIX: lands inside the mount on an embedded console', async () => { + const mount = await completeTheWizard('embedded'); + const target = await exitTarget(); + + expect(lands(target)).toBe('/_console/'); + // The pre-fix spelling — a root-relative '/' — could not satisfy this. + expect(lands(target).startsWith(mount.prefix)).toBe(true); + }); + + it('THE FIX: lands inside the mount on a pinned-base console', async () => { + await completeTheWizard('pinned'); + expect(lands(await exitTarget())).toBe('/_console/'); + }); + + it('is unchanged on the default `/` mount', async () => { + await completeTheWizard('standalone'); + const target = await exitTarget(); + expect(target).toBe('/'); + expect(lands(target)).toBe('/'); + }); + + it('still renames the bootstrap organization before exiting', async () => { + await completeTheWizard('embedded'); + await exitTarget(); + // The exit is the LAST step — the rename this page exists to perform + // must not have been skipped by the redirect change. + expect(authState.updateOrganization).toHaveBeenCalledWith('org_1', { + name: 'Acme Inc.', + slug: 'acme-inc', + }); + expect(authState.switchOrganization).toHaveBeenCalledWith('org_1'); + }); + }); + + describe('the already-signed-in bounce', () => { + async function bounce(name: MountName) { + const mount = mountConsole(name, '/setup'); + stubBootstrapStatus(true); + setupAuth({ user: { id: 'u1' } }); + renderAt(mount.basename, ); + return mount; + } + + it('THE FIX: lands inside the mount on an embedded console', async () => { + await bounce('embedded'); + expect(lands(await exitTarget())).toBe('/_console/'); + }); + + it('THE FIX: lands inside the mount on a pinned-base console', async () => { + await bounce('pinned'); + expect(lands(await exitTarget())).toBe('/_console/'); + }); + + it('is unchanged on the default `/` mount', async () => { + await bounce('standalone'); + expect(await exitTarget()).toBe('/'); + }); + }); + + it('stays a FULL-PAGE navigation, not a router navigation', async () => { + // Load-bearing: ConsoleShell mounts MetadataProvider once auth RESOLVES + // (not once it authenticates) and re-keys it on `language` alone, so the + // app list read while nobody was signed in would survive a router + // navigation and land the new owner in an appless console. See the comment + // on POST_BOOTSTRAP_EXIT in SetupPage. + const mount = mountConsole('embedded', '/setup'); + stubBootstrapStatus(true); + setupAuth({ user: { id: 'u1' } }); + renderAt(mount.basename, ); + + await exitTarget(); + expect(assign).toHaveBeenCalledTimes(1); + // A router navigation would have changed the SPA's location instead. + expect(window.location.pathname).toBe('/_console/setup'); + }); +}); + +// --------------------------------------------------------------------------- +// LoginPage / RegisterPage — the LIFT is behaviour-neutral +// --------------------------------------------------------------------------- + +function postLoginAuth(overrides: Record = {}) { + authState = { + user: { id: 'u1' }, + isLoading: false, + organizations: [{ id: 'org_1' }], + isOrganizationsLoading: false, + activeOrganization: { id: 'org_1' }, + switchOrganization: vi.fn(async () => undefined), + getAuthConfig: vi.fn(async () => ({})), + ...overrides, + }; +} + +describe('LoginPage — redirect behaviour is unchanged by the lift', () => { + it('sends a settled session to the console root, inside the mount', async () => { + const mount = mountConsole('embedded', '/login'); + postLoginAuth(); + renderAt(mount.basename, ); + expect(lands(await exitTarget())).toBe('/_console/'); + }); + + it('honours a safe ?redirect= target, inside the mount', async () => { + const mount = mountConsole('embedded', '/login?redirect=%2Fsettings'); + postLoginAuth(); + renderAt(mount.basename, ); + expect(lands(await exitTarget())).toBe('/_console/settings'); + }); + + it('surfaces the org picker when several orgs and no active one', async () => { + const mount = mountConsole('embedded', '/login'); + postLoginAuth({ + activeOrganization: null, + organizations: [{ id: 'org_1' }, { id: 'org_2' }], + }); + renderAt(mount.basename, ); + expect(lands(await exitTarget())).toBe('/_console/organizations'); + }); + + it('leaves a target that names its OWN absolute SPA mount untouched', async () => { + // The `/_` passthrough branch — re-prefixing would produce + // `/_console/_studio/apps` and 404. + const mount = mountConsole('embedded', '/login?redirect=%2F_studio%2Fapps'); + postLoginAuth(); + renderAt(mount.basename, ); + const target = await exitTarget(); + expect(target).toBe('/_studio/apps'); + expect(lands(target)).toBe('/_studio/apps'); + }); + + it('is unchanged on the default `/` mount', async () => { + const mount = mountConsole('standalone', '/login'); + postLoginAuth(); + renderAt(mount.basename, ); + expect(await exitTarget()).toBe('/'); + }); +}); + +describe('RegisterPage — redirect behaviour is unchanged by the lift', () => { + it('sends a settled session to the console root, inside the mount', async () => { + const mount = mountConsole('embedded', '/register'); + postLoginAuth(); + renderAt(mount.basename, ); + expect(lands(await exitTarget())).toBe('/_console/'); + }); + + it('is unchanged on the default `/` mount', async () => { + const mount = mountConsole('standalone', '/register'); + postLoginAuth(); + renderAt(mount.basename, ); + expect(await exitTarget()).toBe('/'); + }); +}); diff --git a/apps/console/src/utils/consoleBase.test.ts b/apps/console/src/utils/consoleBase.test.ts new file mode 100644 index 0000000000..e77e90ef0d --- /dev/null +++ b/apps/console/src/utils/consoleBase.test.ts @@ -0,0 +1,110 @@ +/** + * `withConsoleBase` — the console mount prefix for full-page navigations + * (objectui#4181). + * + * This file measures the helper against the THREE mount configurations the + * console actually ships in, because the bug it exists to prevent is invisible + * in one of them. Under the default `/` mount the prefixed and unprefixed + * spellings coincide, which is exactly why `SetupPage` could go without the + * helper for as long as it did and no `os dev` run ever noticed. + * + * The assertion is deliberately not "the helper returns string X". It is where + * the returned string LANDS once the browser resolves it against the document's + * base URL — the same resolution `window.location.assign` performs. That is the + * only form in which the embedded case can be stated honestly: there the helper + * returns a RELATIVE url (`'./'`), which is correct precisely because the + * framework CLI injects the `` it resolves against. A string-equality + * test would have to assert `'./'` and would tell the reader nothing about + * whether that lands inside the SPA or outside it. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { withConsoleBase } from './consoleBase'; + +let baseEl: HTMLBaseElement | null = null; + +/** + * Configure a console mount. + * + * @param href the `` the framework CLI injects, or null for a + * bare standalone deployment. + * @param baseUrl what Vite bakes into `import.meta.env.BASE_URL` for that build + * (`'/'` in dev, `'./'` for the shipped embeddable build, + * `'/_console/'` when pinned via `VITE_BASE_PATH`). + */ +function mountConsole(href: string | null, baseUrl: string): void { + baseEl?.remove(); + baseEl = null; + if (href) { + baseEl = document.createElement('base'); + baseEl.setAttribute('href', href); + document.head.appendChild(baseEl); + } + vi.stubEnv('BASE_URL', baseUrl); +} + +/** Where a full-page navigation to `target` actually ends up. */ +function lands(target: string): string { + return new URL(target, document.baseURI).pathname; +} + +afterEach(() => { + baseEl?.remove(); + baseEl = null; + vi.unstubAllEnvs(); +}); + +describe('withConsoleBase', () => { + describe('the shipped embeddable build — relative base + injected ', () => { + // `vite.config.ts` defaults to `base: './'` so one dist/ works under any + // mount point; Vite 8 resolves that to a literal './' for BASE_URL at build + // time. The mount is then communicated at RUNTIME by the injected base href. + it('lands inside the SPA mount', () => { + mountConsole('/_console/', './'); + expect(lands(withConsoleBase('/'))).toBe('/_console/'); + expect(lands(withConsoleBase('/organizations'))).toBe('/_console/organizations'); + }); + + it('THE BUG: an unprefixed path escapes the mount entirely', () => { + mountConsole('/_console/', './'); + // This is the spelling SetupPage carried at both of its exits. It is a + // ROOT-relative url, so the base href cannot reel it back in. + expect(lands('/')).toBe('/'); + expect(lands('/')).not.toBe('/_console/'); + }); + }); + + describe('pinned absolute base (VITE_BASE_PATH)', () => { + it('lands inside the SPA mount', () => { + mountConsole('/_console/', '/_console/'); + expect(withConsoleBase('/')).toBe('/_console/'); + expect(lands(withConsoleBase('/'))).toBe('/_console/'); + expect(lands(withConsoleBase('/organizations'))).toBe('/_console/organizations'); + }); + }); + + describe('the default `/` mount', () => { + it('is a no-op — which is why the bug is invisible on a standalone run', () => { + mountConsole(null, '/'); + expect(withConsoleBase('/')).toBe('/'); + expect(withConsoleBase('/organizations')).toBe('/organizations'); + // The prefixed and unprefixed spellings are indistinguishable here. + expect(lands(withConsoleBase('/'))).toBe(lands('/')); + }); + }); + + describe('paths that already name their own absolute SPA mount', () => { + it('passes `/_`-prefixed targets through untouched', () => { + mountConsole('/_console/', '/_console/'); + expect(withConsoleBase('/_studio/apps')).toBe('/_studio/apps'); + expect(withConsoleBase('/_account')).toBe('/_account'); + // Not re-prefixed into `/_console/_studio/apps`. + expect(lands(withConsoleBase('/_studio/apps'))).toBe('/_studio/apps'); + }); + }); + + it('tolerates a relative path by making it absolute against the mount', () => { + mountConsole('/_console/', '/_console/'); + expect(withConsoleBase('organizations')).toBe('/_console/organizations'); + }); +}); diff --git a/apps/console/src/utils/consoleBase.ts b/apps/console/src/utils/consoleBase.ts new file mode 100644 index 0000000000..ea5ed2b882 --- /dev/null +++ b/apps/console/src/utils/consoleBase.ts @@ -0,0 +1,44 @@ +/** + * Console mount base — the prefix a FULL-PAGE navigation needs. + * + * Lifted out of `pages/auth/LoginPage` (objectui#4181), where it was + * module-private. It had already been copied once into `RegisterPage`, and + * `SetupPage` — which needed it just as much — silently went without: its two + * first-run exits navigated to a bare `'/'` and dropped a brand-new owner + * outside the SPA on the first screen after creating their account. One + * implementation, three callers, so the next basename fix lands in one place. + * + * ## The rule + * + * `window.location.assign` bypasses React Router's `basename`, so a path + * produced by the router (e.g. `?redirect=/settings` — already + * basename-stripped) or a literal like `/organizations` would resolve to + * `http://host/settings`, missing the `/_console` mount and 404-ing. + * + * ## Why reading `BASE_URL` is the correct source, in all three mounts + * + * The router takes its basename from the injected `` + * (`App.tsx:resolveBasename`) while this helper reads `import.meta.env.BASE_URL` + * — two different sources, which is worth spelling out because they agree only + * for a reason, not by construction: + * + * - **dev / default `/` mount** — Vite resolves a relative `base` to `'/'` in + * serve mode, so this is a no-op prefix and both spellings coincide. This is + * why a standalone `os dev` run can never reveal the bug. + * - **embedded, the shipped default** (`vite.config.ts` `base: './'`, no + * `VITE_BASE_PATH`) — `BASE_URL` is the literal `'./'`, so this returns a + * RELATIVE url (`'./'`, `'./organizations'`). `location.assign` resolves it + * against the document base URL, which is exactly the `` + * the framework CLI injects — the same element the router reads. So the two + * sources land on one answer via the browser, not via a shared constant. + * - **pinned absolute base** (`VITE_BASE_PATH=/_console/`) — `BASE_URL` is + * `'/_console/'` and this returns an absolute `/_console/…`. + * + * Paths already targeting another absolute SPA mount (`/_studio`, `/_account`, + * …) pass through untouched. + */ +export function withConsoleBase(path: string): string { + if (path.startsWith('/_')) return path; + const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, ''); + return base + (path.startsWith('/') ? path : `/${path}`); +}