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}`);
+}