Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/setup-exit-console-basename-4181.md
Original file line number Diff line number Diff line change
@@ -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 `<base href>` — 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.
19 changes: 4 additions & 15 deletions apps/console/src/pages/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
11 changes: 3 additions & 8 deletions apps/console/src/pages/auth/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Link to={props.href} className={props.className}>
Expand Down
32 changes: 30 additions & 2 deletions apps/console/src/pages/auth/SetupPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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' }),
Expand Down
Loading
Loading