diff --git a/.changeset/metadata-driven-landing.md b/.changeset/metadata-driven-landing.md new file mode 100644 index 000000000..059819d54 --- /dev/null +++ b/.changeset/metadata-driven-landing.md @@ -0,0 +1,26 @@ +--- +'@object-ui/console': minor +--- + +feat(console): resolve the post-login landing from app metadata, not a hardcode + +The root route (`/`) previously redirected via a hardcoded +`PREFERRED_APPS = ['cloud_control']` in `CloudAwareRootRedirect` — baking one +product's policy (cloud) into the shared Console, with no supported way for a +deployment to opt out of the `/home` launcher or land somewhere custom without +forking the SPA. + +`CloudAwareRootRedirect` is replaced by `RootLandingRedirect`, which resolves the +landing purely from app metadata (`resolveLandingPath`, unit-tested): + +1. the app marked `isDefault: true` → `/apps/` (its own `homePageId` then + selects the landing page within it); +2. else the single visible app (`active !== false && hidden !== true`) → that app; +3. else `/home` — the multi-app workspace launcher (legacy default). + +This gives `isDefault` **routing semantics** (it was a display-only badge) — a +back-compat-relevant contract change. Back-compat: a deployment with no +`isDefault` app and ≥2 visible apps still lands on `/home`, exactly as before; +cloud is unaffected (`cloud_control` is already `isDefault: true`) and the +cloud-specific hardcode is removed. The landing is now a build-time product +decision a developer declares in metadata, not a runtime Settings-UI preference. diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index b4a141c15..1281e362f 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -39,7 +39,7 @@ import { } from '@object-ui/app-shell'; import { AppContent } from './AppContent'; -import { CloudAwareRootRedirect } from './components/CloudAwareRootRedirect'; +import { RootLandingRedirect } from './components/RootLandingRedirect'; import { FormPage } from './components/FormPage'; import { MetadataHmrReloader } from './components/MetadataHmrReloader'; import SharedRecordPage from './pages/SharedRecordPage'; @@ -292,7 +292,7 @@ export function App() { } /> - } /> + } /> } /> diff --git a/apps/console/src/components/CloudAwareRootRedirect.tsx b/apps/console/src/components/CloudAwareRootRedirect.tsx deleted file mode 100644 index 7ee5ad289..000000000 --- a/apps/console/src/components/CloudAwareRootRedirect.tsx +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * CloudAwareRootRedirect — element for ``. - * - * Replaces app-shell's stock `RootRedirect` (which always sends to - * `/home`). The same Console bundle is served by both apps/cloud - * (control plane) and apps/objectos (per-project runtime); we want - * each to land on the most useful App for that host without baking - * a Vite env var into the build. - * - * Strategy: inspect the loaded metadata `apps` list and prefer a - * cloud-control App when present (apps/cloud). Otherwise fall back - * to `/home`, preserving the legacy behaviour for apps/objectos. - * - * Preference order: - * 1. `cloud_control` — apps/cloud's control plane App - * 2. `setup` — apps/cloud second-best fallback - * 3. `/home` — original RootRedirect behaviour - */ - -import { Navigate } from 'react-router-dom'; -import { useMetadata, LoadingFallback } from '@object-ui/app-shell'; - -const PREFERRED_APPS = ['cloud_control']; - -export function CloudAwareRootRedirect() { - const { apps, loading } = useMetadata(); - if (loading) return ; - - const appNames = new Set((apps ?? []).map((a: any) => a?.name).filter(Boolean)); - for (const candidate of PREFERRED_APPS) { - if (appNames.has(candidate)) { - return ; - } - } - - return ; -} diff --git a/apps/console/src/components/RootLandingRedirect.test.ts b/apps/console/src/components/RootLandingRedirect.test.ts new file mode 100644 index 000000000..4dd7a3fec --- /dev/null +++ b/apps/console/src/components/RootLandingRedirect.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { resolveLandingPath } from './RootLandingRedirect'; + +describe('resolveLandingPath', () => { + it('routes to the App marked isDefault (isDefault now ROUTES, not just badges)', () => { + expect( + resolveLandingPath([ + { name: 'crm' }, + { name: 'cloud_control', isDefault: true }, + { name: 'setup' }, + ]), + ).toBe('/apps/cloud_control'); + }); + + it('prefers isDefault over the single-visible-app rule', () => { + expect( + resolveLandingPath([{ name: 'a', isDefault: true }, { name: 'b' }, { name: 'c' }]), + ).toBe('/apps/a'); + }); + + it('lands directly in the single visible App when none is isDefault', () => { + expect(resolveLandingPath([{ name: 'only_app' }])).toBe('/apps/only_app'); + }); + + it('ignores hidden/inactive Apps when counting "single visible"', () => { + expect( + resolveLandingPath([ + { name: 'main' }, + { name: 'secret', hidden: true }, + { name: 'off', active: false }, + ]), + ).toBe('/apps/main'); + }); + + it('falls back to /home for a multi-app deployment with no isDefault (legacy behavior)', () => { + expect(resolveLandingPath([{ name: 'a' }, { name: 'b' }])).toBe('/home'); + }); + + it('falls back to /home when there are no apps', () => { + expect(resolveLandingPath([])).toBe('/home'); + expect(resolveLandingPath(null)).toBe('/home'); + expect(resolveLandingPath(undefined)).toBe('/home'); + }); + + it('ignores entries without a name', () => { + expect(resolveLandingPath([{ isDefault: true }, { name: 'real' }])).toBe('/apps/real'); + }); +}); diff --git a/apps/console/src/components/RootLandingRedirect.tsx b/apps/console/src/components/RootLandingRedirect.tsx new file mode 100644 index 000000000..57967185c --- /dev/null +++ b/apps/console/src/components/RootLandingRedirect.tsx @@ -0,0 +1,62 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * RootLandingRedirect — element for ``. + * + * Resolves the post-login landing from APP METADATA, so any product built on + * the framework (cloud's control plane, an ISV's product, a per-project + * runtime) declares its landing in source rather than forking the Console. This + * replaces the previous hardcoded `PREFERRED_APPS = ['cloud_control']` redirect, + * which baked one product's policy into the shared bundle. + * + * The landing is a build/dev-time PRODUCT decision, declared in metadata — not a + * runtime, per-tenant, Settings-UI preference. Resolution order (see + * {@link resolveLandingPath}): + * 1. the App marked `isDefault: true` → `/apps/` — and that App's own + * `homePageId` then selects the landing page within it; + * 2. else the single visible App (`active !== false && hidden !== true`) + * → `/apps/` (a one-app deployment shouldn't show a one-tile launcher); + * 3. else `/home` — the multi-app workspace launcher (the legacy default). + * + * NOTE: this gives `isDefault` ROUTING semantics; it was previously a + * display-only badge. Back-compat: a deployment with no `isDefault` App and ≥2 + * visible Apps still lands on `/home`, exactly as before. (A deploy-time ops + * override is intentionally kept server-side, not read here — the metadata + * declaration is the source of truth.) + */ + +import { Navigate } from 'react-router-dom'; +import { useMetadata, LoadingFallback } 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; +} + +/** + * The path `/` should redirect to, resolved purely from the App list. Extracted + * from the component so the policy is unit-testable without a router/render. + */ +export function resolveLandingPath(apps: readonly LandingApp[] | null | undefined): string { + const list = (apps ?? []).filter((a): a is LandingApp & { name: string } => Boolean(a?.name)); + + // 1. The App the product declared as default (isDefault now ROUTES, not just badges). + 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). + const visible = list.filter((a) => a.active !== false && a.hidden !== true); + if (visible.length === 1) return `/apps/${visible[0].name}`; + + // 3. Multi-app default: the workspace launcher. + return '/home'; +} + +export function RootLandingRedirect() { + const { apps, loading } = useMetadata(); + if (loading) return ; + return ; +}