diff --git a/.changeset/sso-landing-setup-only-home-4048.md b/.changeset/sso-landing-setup-only-home-4048.md new file mode 100644 index 0000000000..bff660badb --- /dev/null +++ b/.changeset/sso-landing-setup-only-home-4048.md @@ -0,0 +1,36 @@ +--- +'@object-ui/console': patch +--- + +fix(console): a Setup-only environment lands on `/home`, not Setup's all-zero System Overview + +A new builder arriving on a just-created environment (platform SSO, no explicit +target) landed on Setup's **System Overview** — a platform-health/audit +dashboard reading all zeros, because a fresh environment has no audit history +yet. The intended first screen is the environment's own home: build with AI, +start from a template, Your apps. + +The path was `resolveLandingPath`'s rule 2. Measured end to end: + + / → RootLandingRedirect → resolveLandingPath([setup]) + → rule 2 "single visible app" → /apps/setup + → AppContent.resolveLandingRoute() → the app's first nav item + → dashboard/system_overview + +Rule 2 itself is right — a one-app PRODUCT deployment should not have to click +through a one-tile launcher. Setup is not that app: it is the platform +administration console that `@objectstack/platform-objects` ships into every +deployment, so "the only app this viewer can see is Setup" means *this +environment has no product apps yet*, not *Setup is the product*. Under ADR-0075 +the environment layer's home is the environment's own responsibility, so that +case now resolves `/home`. + +Deliberately narrow — everything else is byte-identical: + +- a declared landing still wins (rule 1, `isDefault`, untouched): an admin + console that genuinely wants Setup first says so, and gets it; +- a one-app product deployment still lands in its app; +- `[product, setup]` still resolves `/home` exactly as before — Setup is + excluded from the single-app *outcome*, never from the visible *count*; +- the `/setup` deep link is unchanged: `/` is "an arrival with no target", + `/setup` is an explicit one, and it still resolves into Setup. diff --git a/apps/console/src/components/RootLandingRedirect.test.ts b/apps/console/src/components/RootLandingRedirect.test.ts index aed22b5482..9e56d3a645 100644 --- a/apps/console/src/components/RootLandingRedirect.test.ts +++ b/apps/console/src/components/RootLandingRedirect.test.ts @@ -53,6 +53,80 @@ describe('resolveLandingPath', () => { expect(resolveLandingPath([{ name: 'a' }, { name: 'b' }])).toBe('/home'); }); + // ── objectui#4048 — Setup is not a "one-app deployment" ────────────── + // + // A new builder arriving on a just-created environment (platform SSO, no + // explicit target) landed on Setup's System Overview — an audit/health + // dashboard reading all zeros, because a fresh environment HAS no audit + // history. The chain, measured end to end: + // + // `/` → RootLandingRedirect → resolveLandingPath([setup]) + // → rule 2 "single visible app" → `/apps/setup` + // → AppContent.resolveLandingRoute() → first nav item + // → `dashboard/system_overview` + // + // Rule 2 is right — a one-app PRODUCT deployment shouldn't have to click + // through a one-tile launcher (#2027). Setup is not that app: it is the + // platform administration console shipped by @objectstack/platform-objects + // into every deployment, so "the only app I can see is Setup" means "this + // environment has no product apps yet", not "Setup is the product". Under + // ADR-0075 the environment layer's home is the environment's own + // responsibility, and `/home` — build-with-AI, start-from-a-template, Your + // apps — is that home. + describe('objectui#4048 — a Setup-only deployment lands on the env home', () => { + const SETUP = { name: 'setup', _packageId: 'com.objectstack.setup' }; + + it('THE FIX: Setup as the only visible app resolves /home, not the audit dashboard', () => { + expect(resolveLandingPath([SETUP])).toBe('/home'); + }); + + it('matches Setup by package id even when it carries a different name', () => { + // ADR-0048 keys the app on its package id; the name is a per-tenant alias. + expect(resolveLandingPath([{ name: 'platform_admin', _packageId: 'com.objectstack.setup' }])).toBe( + '/home', + ); + }); + + it('matches a package-less Setup by name — runtime/DB apps carry no _packageId', () => { + // Same fallback order `resolveSetupAppPath` uses, so the two resolvers + // cannot disagree about which app is Setup. + expect(resolveLandingPath([{ name: 'setup' }])).toBe('/home'); + }); + + it('still lands in Setup when the deployment DECLARES it (ADR-0075 wins over the default)', () => { + // The declared landing is the escape hatch: an admin-console deployment + // that genuinely wants Setup as its first screen says so with + // `isDefault`, and rule 1 is deliberately untouched by this fix. + expect(resolveLandingPath([{ ...SETUP, isDefault: true }])).toBe('/apps/setup'); + }); + + it('CONTROL: a one-app PRODUCT deployment still lands in its app (#2027 rule 2 intact)', () => { + expect(resolveLandingPath([{ name: 'crm' }])).toBe('/apps/crm'); + }); + + it('CONTROL: Setup alongside a product app is unchanged — this fix only reads the SINGLE-app case', () => { + // Every real deployment ships Setup. Excluding it from the *count* would + // silently re-route `[crm, setup]` from /home into /apps/crm — a far + // bigger behavior change than this card asks for, and not this fix. + expect(resolveLandingPath([{ name: 'crm' }, SETUP])).toBe('/home'); + expect(resolveLandingPath([{ name: 'crm' }, SETUP, { name: 'showcase' }])).toBe('/home'); + }); + + it('CONTROL: a product app stays the landing when Setup is hidden/inactive beside it', () => { + expect(resolveLandingPath([{ name: 'crm' }, { ...SETUP, hidden: true }])).toBe('/apps/crm'); + }); + + it('Studio alone is deliberately NOT redirected — the workbench IS a builder surface', () => { + // Scoped to Setup on purpose. Landing a builder in Studio is defensible + // (it is where you build); landing them on an all-zero audit dashboard is + // not. Widening this to every platform app is a separate judgment call + // that this card does not authorize. + expect(resolveLandingPath([{ name: 'studio', _packageId: 'com.objectstack.studio' }])).toBe( + '/apps/studio', + ); + }); + }); + it('falls back to /home when there are no apps', () => { expect(resolveLandingPath([])).toBe('/home'); expect(resolveLandingPath(null)).toBe('/home'); diff --git a/apps/console/src/components/RootLandingRedirect.tsx b/apps/console/src/components/RootLandingRedirect.tsx index f239673a3c..cdcc9f03d0 100644 --- a/apps/console/src/components/RootLandingRedirect.tsx +++ b/apps/console/src/components/RootLandingRedirect.tsx @@ -17,7 +17,9 @@ * be selectable with `homePageId`, retired in spec 17.0.0 * (objectstack#4667 / #4709); * 2. else the single visible App (`active !== false && hidden !== true`) - * → `/apps/` (a one-app deployment shouldn't show a one-tile launcher); + * → `/apps/` (a one-app deployment shouldn't show a one-tile launcher) + * — UNLESS that one App is the platform's built-in Setup console, which + * means the environment has no product apps yet (objectui#4048, below); * 3. else `/home` — the multi-app workspace launcher (the legacy default). * * NOTE: this gives `isDefault` ROUTING semantics; it was previously a @@ -28,7 +30,12 @@ */ import { Navigate } from 'react-router-dom'; -import { useMetadata, LoadingFallback } from '@object-ui/app-shell'; +import { + useMetadata, + LoadingFallback, + SETUP_APP_PACKAGE_ID, + SETUP_APP_NAME, +} from '@object-ui/app-shell'; /** Minimal shape this resolver needs off each App metadata record. */ interface LandingApp { @@ -36,6 +43,20 @@ interface LandingApp { isDefault?: boolean; active?: boolean; hidden?: boolean; + /** ADR-0048 owning package — the canonical identity of a shipped app. */ + _packageId?: string; +} + +/** + * Is this the platform's built-in Setup console (objectui#4048)? + * + * Package id first, app name as the fallback — the SAME order + * `resolveSetupAppPath` resolves Setup in, so the `/` landing policy and the + * `/setup` deep link cannot disagree about which app Setup is. A runtime/DB + * app carries no `_packageId`, which is why the name alias is kept. + */ +function isPlatformSetupApp(app: LandingApp): boolean { + return app._packageId === SETUP_APP_PACKAGE_ID || app.name === SETUP_APP_NAME; } /** @@ -49,9 +70,32 @@ export function resolveLandingPath(apps: readonly LandingApp[] | null | undefine const defaultApp = list.find((a) => a.isDefault === true); if (defaultApp) return `/apps/${defaultApp.name}`; - // 2. A single-app deployment lands straight in that App (no one-tile launcher). + // 2. A single-app deployment lands straight in that App (no one-tile + // launcher) — but Setup alone is NOT a one-app deployment. + // + // objectui#4048: a new builder arriving on a just-created environment + // landed on Setup's System Overview, an audit/health dashboard reading all + // zeros because a fresh environment has no audit history yet. The path was + // this rule: the only app such a viewer can see is the platform + // administration console that @objectstack/platform-objects ships into + // EVERY deployment, so `visible.length === 1` was true and the resolver + // read it as "this deployment's product is Setup". `/apps/setup` then + // resolves to the app's first navigation item (`AppContent + // .resolveLandingRoute`), which for Setup is `dashboard/system_overview`. + // + // "The only app I can see is Setup" means the environment has no product + // apps yet — and under ADR-0075 the environment layer's home is the + // environment's own responsibility, which is `/home` (build with AI, start + // from a template, Your apps). Rule 1 is deliberately left alone: a + // deployment that genuinely wants Setup as its first screen DECLARES it + // with `isDefault`, and a declared landing still wins. + // + // Scoped to the SINGLE-app case on purpose. Dropping Setup from the + // `visible` count instead would re-route every ordinary `[product, setup]` + // deployment from `/home` into the product app — a far larger change than + // this card asks for, and one nobody measured. const visible = list.filter((a) => a.active !== false && a.hidden !== true); - if (visible.length === 1) return `/apps/${visible[0].name}`; + if (visible.length === 1 && !isPlatformSetupApp(visible[0])) return `/apps/${visible[0].name}`; // 3. Multi-app default: the workspace launcher. return '/home'; diff --git a/apps/console/src/components/SetupRoute.test.tsx b/apps/console/src/components/SetupRoute.test.tsx index 710569a8a2..c853773de0 100644 --- a/apps/console/src/components/SetupRoute.test.tsx +++ b/apps/console/src/components/SetupRoute.test.tsx @@ -249,6 +249,24 @@ describe('/setup deep link — the routed chain', () => { expect(pathname()).toBe('/setup'); }); + it('CONTROL (objectui#4048): the deep link still wins on a Setup-ONLY deployment', async () => { + // #4048 changed `resolveLandingPath([setup])` from `/apps/setup` to + // `/home`, so this is the one app list where the two policies could be + // confused for each other. They are different questions and must stay + // that way: `/` is "where does an arrival with NO target belong" (the env + // home), `/setup` is an EXPLICIT target and still resolves into Setup. + // Asserted on the same list the fix redirects, because a control drawn + // from a multi-app list could not have caught a regression here. + auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } }; + apps = [SETUP_APP]; + expect(resolveLandingPath([SETUP_APP])).toBe('/home'); + + renderSetupDeepLink(); + expect(await screen.findByTestId('app-subtree')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/com.objectstack.setup'); + expect(screen.queryByTestId('home-launcher')).not.toBeInTheDocument(); + }); + it('a viewer whose metadata has no Setup app gets "app not available", not home', async () => { // `SETUP_APP.requiredPermissions = ['setup.access']`, so this is a normal // permission outcome. The target is the canonical package-id URL, which