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
36 changes: 36 additions & 0 deletions .changeset/sso-landing-setup-only-home-4048.md
Original file line numberDiff line numberDiff line change
@@ -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.
74 changes: 74 additions & 0 deletions apps/console/src/components/RootLandingRedirect.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand Down
52 changes: 48 additions & 4 deletions apps/console/src/components/RootLandingRedirect.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<it>` (a one-app deployment shouldn't show a one-tile launcher);
* → `/apps/<it>` (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
Expand All@@ -28,14 +30,33 @@
*/

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 {
name?: string;
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;
}

/**
Expand All@@ -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';
Expand Down
18 changes: 18 additions & 0 deletions apps/console/src/components/SetupRoute.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Loading