From b5f47b5a025ff0f776a218ef528c95cf6b558ed7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 17:42:29 +0000 Subject: [PATCH] fix(console): /_console/setup is a stable deep link into platform administration (#2794) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/setup` bounced signed-in visitors to `/home`, so system settings had no direct URL — unbookmarkable, unshareable, and asymmetric with Studio's stable front door. The route was never missing, it was occupied: `/setup` mounts the first-run owner-bootstrap wizard, which evicts a signed-in visitor with `window.location.assign('/')`, and the landing resolver turns `/` into `/home` on any multi-app deployment. `/setup` now discriminates on the condition the wizard itself probes — whether the deployment has an owner. No owner: the wizard, unchanged. Otherwise: `SetupRedirect`, a new app-shell alias beside `SystemRedirect` that resolves the Setup app from metadata through the same `appRouteSegment()` helper the home launcher's app cards use, and forwards to the app root so `AppContent`'s existing landing resolution picks the page. An unauthenticated deep link now travels the host's own auth-redirect contract (`/login?redirect=%2Fsetup`, router-derived, basename-safe) and returns here after signing in. A viewer whose metadata carries no Setup app — usually a missing `setup.access` permission — gets the shell's ordinary "App not available" screen rather than a silent landing on home. `/_console/studio` was checked for the same asymmetry and needed no change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .changeset/setup-deep-link-2794.md | 18 ++ apps/console/src/App.tsx | 44 +-- .../console/src/components/ProtectedRoute.tsx | 53 ++++ .../src/components/SetupRoute.test.tsx | 263 ++++++++++++++++++ apps/console/src/components/SetupRoute.tsx | 34 +++ apps/console/src/components/setupEntry.ts | 138 +++++++++ .../app-shell/src/console/ConsoleShell.tsx | 83 ++++++ .../__tests__/setupRedirectTarget.test.tsx | 161 +++++++++++ packages/app-shell/src/index.ts | 6 + 9 files changed, 764 insertions(+), 36 deletions(-) create mode 100644 .changeset/setup-deep-link-2794.md create mode 100644 apps/console/src/components/ProtectedRoute.tsx create mode 100644 apps/console/src/components/SetupRoute.test.tsx create mode 100644 apps/console/src/components/SetupRoute.tsx create mode 100644 apps/console/src/components/setupEntry.ts create mode 100644 packages/app-shell/src/console/__tests__/setupRedirectTarget.test.tsx diff --git a/.changeset/setup-deep-link-2794.md b/.changeset/setup-deep-link-2794.md new file mode 100644 index 0000000000..cddb0a694a --- /dev/null +++ b/.changeset/setup-deep-link-2794.md @@ -0,0 +1,18 @@ +--- +'@object-ui/console': patch +'@object-ui/app-shell': patch +--- + +`/setup` is a real address again — the console gets a stable deep link into platform administration instead of bouncing you back to home + +Opening `/_console/setup` landed on `/_console/home`. System settings had no direct URL at all: the only way in was clicking the 「系统设置」 card on the home launcher, which meant the entry point could not be bookmarked, could not be pasted into a support runbook, and was asymmetric with Studio, whose front door has been stable for a while. + +The route was never missing — it was occupied. `/setup` mounts the first-run owner-bootstrap wizard (ported here when the Account SPA was retired), and that page evicts everyone it is not meant for: a signed-in visitor via `window.location.assign('/')`, which the landing resolver then turns into `/home` on any multi-app deployment. So the bounce was the wizard doing its job at a URL that had quietly acquired a second, more common meaning. + +`/setup` now decides between the two, on the condition the wizard itself already probes — whether the deployment has an owner (`GET /api/v1/auth/bootstrap-status`). No owner yet: the wizard, unchanged. Otherwise: the platform-administration deep link. A live session short-circuits the probe entirely, because `hasOwner: false` cannot be true while somebody is signed in — which also keeps a failed probe from re-creating the bounce it is meant to remove. The verdict is latched for the lifetime of the mount, because `signUp()` flips the session to authenticated while the wizard is still renaming the bootstrap organization, and re-deciding on that flip would unmount the wizard mid-submission. + +The destination is read from metadata rather than spelled out. `SetupRedirect` (new, exported from `@object-ui/app-shell` alongside `SystemRedirect`, with its policy available as the pure `resolveSetupAppPath`) resolves the Setup app through the same `appRouteSegment()` helper the home launcher's app cards use, and forwards to the app ROOT — so the page you land on is whatever `AppContent` already resolves as that app's landing item, not a second copy of that policy that would drift the next time Setup's navigation is re-ordered. Search and hash carry across the hop, as they do for `SystemRedirect`. + +Two edges are handled rather than papered over. An unauthenticated deep link now goes to `/login?redirect=%2Fsetup` through the console's existing auth-redirect contract — router-derived, so it stays correct under a `` mount — and lands back on `/setup` after signing in; previously it reached a bare `/login` and the deep link was dropped. And a viewer whose metadata contains no Setup app (the common cause is not a broken build but a missing `setup.access` permission, which filters the app out server-side) gets the shell's ordinary "App not available" screen, with its retry and its one-shot metadata re-check — never a silent landing on home, and never the bare `/apps/setup` pseudo-route, which would have resolved to whichever app happens to be the default. + +`/_console/studio` was checked for the same asymmetry and needed no change: bare `/studio` is a declared front door rendering the builder landing, and `/studio/:packageId` already redirects to its Data pillar. diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index a577cbc688..e01352a185 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -12,9 +12,9 @@ * with extra `` children. */ -import { type ReactNode, useEffect } from 'react'; +import { useEffect } from 'react'; import { BrowserRouter, Routes, Route, Navigate, useLocation, Link } from 'react-router-dom'; -import { AuthProvider, AuthGuard, useAuth } from '@object-ui/auth'; +import { AuthProvider, useAuth } from '@object-ui/auth'; import { DevMasterDetail } from './dev/DevMasterDetail'; import { DevLists } from './dev/DevLists'; import { DevModal } from './dev/DevModal'; @@ -22,11 +22,8 @@ import { DevLookup } from './dev/DevLookup'; import { DevRowActions } from './dev/DevRowActions'; import { ConsoleShell, - ConnectedShell, - RequireOrganization, RequireAiSurface, SystemRedirect, - LoadingFallback, ConsoleToaster, DefaultHomeLayout, DefaultHomePage, @@ -46,6 +43,8 @@ import { import { AppContent } from './AppContent'; import { RootLandingRedirect } from './components/RootLandingRedirect'; +import { ProtectedRoute } from './components/ProtectedRoute'; +import { SetupRoute } from './components/SetupRoute'; import { FormPage } from './components/FormPage'; import { MetadataHmrReloader } from './components/MetadataHmrReloader'; import SharedRecordPage from './pages/SharedRecordPage'; @@ -60,7 +59,6 @@ import { ResetPasswordPage } from './pages/auth/ResetPasswordPage'; import { SetPasswordPage } from './pages/auth/SetPasswordPage'; import { VerifyEmailPage } from './pages/auth/VerifyEmailPage'; import { VerifyEmailPromptPage } from './pages/auth/VerifyEmailPromptPage'; -import { SetupPage } from './pages/auth/SetupPage'; import { OAuthConsentPage } from './pages/auth/OAuthConsentPage'; import { DeviceAuthPage } from './pages/auth/DeviceAuthPage'; @@ -96,35 +94,6 @@ function resolveBasename(): string { const BASENAME = resolveBasename(); -/** - * ProtectedRoute — replaces app-shell's AuthenticatedRoute. Same composition - * (AuthGuard + ConnectedShell + optional RequireOrganization) but redirects - * unauthenticated visitors to the Console-hosted /login (preserving the - * original Console path as `?redirect=…`). - */ -function LoginRedirect() { - const location = useLocation(); - const redirect = location.pathname + location.search; - const search = redirect && redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : ''; - return ; -} - -function ProtectedRoute({ - children, - requireOrganization = true, -}: { - children: ReactNode; - requireOrganization?: boolean; -}) { - return ( - } loadingFallback={}> - - {requireOrganization ? {children} : children} - - - ); -} - /** Wraps `DefaultHomeLayout` so the FAB gets the signed-in user id. */ function HomeRoute() { const { user } = useAuth(); @@ -180,7 +149,10 @@ export function App() { } /> } /> } /> - } /> + {/* Public ONLY while the deployment is un-bootstrapped — see + * SetupRoute. Once an owner exists this is the platform-settings + * deep link and carries this host's normal auth guard. */} + } /> } /> } /> {/* diff --git a/apps/console/src/components/ProtectedRoute.tsx b/apps/console/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000000..cafc292210 --- /dev/null +++ b/apps/console/src/components/ProtectedRoute.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Console's authenticated-route wrapper and its auth-redirect contract. + * + * Lifted out of `App.tsx` verbatim (objectui#2794) so routes declared in their + * own modules — and tests that need to exercise the REAL redirect rather than a + * transcription of it — can reach the same one. Both were module-private in + * `App.tsx`, so the move adds a seam without widening any published surface, + * and nothing about the behaviour changed. + */ + +import type { ReactNode } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { AuthGuard } from '@object-ui/auth'; +import { ConnectedShell, RequireOrganization, LoadingFallback } from '@object-ui/app-shell'; + +/** + * Where an unauthenticated visitor to a protected route goes. + * + * The `?redirect=` target is read off the ROUTER's location, never + * `window.location` — the Console is mounted under a `` basename in + * every embedded deployment, so a window-derived path would carry the `/_console` + * mount into a value that `LoginPage` then re-prefixes with it (objectui#4168). + */ +export function LoginRedirect() { + const location = useLocation(); + const redirect = location.pathname + location.search; + const search = redirect && redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : ''; + return ; +} + +/** + * ProtectedRoute — replaces app-shell's AuthenticatedRoute. Same composition + * (AuthGuard + ConnectedShell + optional RequireOrganization) but redirects + * unauthenticated visitors to the Console-hosted /login (preserving the + * original Console path as `?redirect=…`). + */ +export function ProtectedRoute({ + children, + requireOrganization = true, +}: { + children: ReactNode; + requireOrganization?: boolean; +}) { + return ( + } loadingFallback={}> + + {requireOrganization ? {children} : children} + + + ); +} diff --git a/apps/console/src/components/SetupRoute.test.tsx b/apps/console/src/components/SetupRoute.test.tsx new file mode 100644 index 0000000000..710569a8a2 --- /dev/null +++ b/apps/console/src/components/SetupRoute.test.tsx @@ -0,0 +1,263 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/setup` is a stable deep link into platform administration, and still the + * first-run wizard on a deployment that has no owner yet (objectui#2794). + * + * ## The premise, re-measured at this tip + * + * The card reported `/_console/setup` bouncing back to `/_console/home`. The + * route was never missing — it was OCCUPIED. `App.tsx` mounted the first-run + * owner-bootstrap wizard there (`pages/auth/SetupPage`, ported from the retired + * Account SPA), and that page evicts everyone it is not for: + * + * SetupPage.tsx `if (user && !submitting) window.location.assign('/')` + * SetupPage.tsx `if (bootstrapped === true && !user) navigate('/login', …)` + * + * So a signed-in admin opening the deep link was sent to `/`, which + * `RootLandingRedirect.resolveLandingPath()` resolves to `/home` on any + * multi-app deployment with no `isDefault` app — the card's exact observation, + * and a full-page `location.assign`, which is why it read as "被重定向回 home" + * rather than as a routing error. The unauthenticated half was defective too, in + * a quieter way: it reached `/login` with no `?redirect=`, so signing in dropped + * the deep link on the floor. + * + * ## What is asserted here + * + * The chain, not the screen. `/` in the harness below is wired to the REAL + * `resolveLandingPath` over a multi-app metadata list, so `/home` is a place the + * router can actually settle on — "never lands on home" is then an assertion + * about a reachable destination rather than about a stub that was never wired. + * + * `SetupPage` and `ConnectedShell` are stubbed: this file measures ROUTING (which + * surface `/setup` resolves to, and which URL results), not the wizard's form or + * the shell's provider stack. `AuthGuard` and `LoginRedirect` are REAL — the + * `?redirect=` contract is one of the things under test, and a transcription of + * it would be free to agree with itself. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, waitFor, renderHook, act } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'; + +/** Auth facts, swapped per test. `AuthGuard` itself stays real. */ +let auth = { isAuthenticated: false, isLoading: false, user: null as unknown }; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => auth, +})); + +// `AuthGuard` reaches `useAuth` through the auth package's OWN module graph +// (`./useAuth`), not through its entry point — so overriding the entry alone +// leaves the REAL guard reading the REAL context and every authenticated case +// silently falls to the login branch. Mock the module id the guard actually +// imports; `@object-ui/auth` is aliased to `packages/auth/src` by +// `apps/console/vite.config.ts`, which is what makes these the same module. +vi.mock('../../../../packages/auth/src/useAuth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => auth, +})); + +/** App metadata the shell has loaded, swapped per test. */ +let apps: unknown[] = []; + +vi.mock('../../../../packages/app-shell/src/providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => ({ apps, objects: [], loading: false, error: null, refresh: async () => {} }), +})); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + // Pass-through: the provider stack is not what decides this question. + ConnectedShell: ({ children }: { children?: React.ReactNode }) => <>{children}, + RequireOrganization: ({ children }: { children?: React.ReactNode }) => <>{children}, + LoadingFallback: () =>
, +})); + +vi.mock('../pages/auth/SetupPage', () => ({ + SetupPage: () =>
owner bootstrap
, +})); + +import { resolveLandingPath } from './RootLandingRedirect'; +import { decideSetupEntry, useSetupEntryMode } from './setupEntry'; +import { SetupRoute } from './SetupRoute'; + +const SETUP_APP = { name: 'setup', label: 'Setup', _packageId: 'com.objectstack.setup' }; +/** Multi-app, no `isDefault` — the shape whose `/` resolves to `/home`. */ +const MULTI_APP = [{ name: 'crm' }, SETUP_APP, { name: 'showcase' }]; + +function LocationProbe() { + const { pathname, search } = useLocation(); + return
{`${pathname}${search}`}
; +} + +/** + * The reference host's route tree, reduced to the legs this question travels: + * `/setup`, the login surface it may bounce to, the app subtree it should reach, + * and the `/` → landing → `/home` chain it must NOT. + */ +function renderSetupDeepLink() { + return render( + + + + } /> + login
} /> + app} /> + home} /> + {/* The REAL landing policy, so `/home` is genuinely reachable here. */} + } /> + } /> + + , + ); +} + +const pathname = () => screen.getByTestId('pathname').textContent; + +/** `hasOwner` the probe will report. */ +function stubBootstrapStatus(hasOwner: boolean | 'network-error') { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + if (hasOwner === 'network-error') throw new Error('offline'); + return { ok: true, json: async () => ({ hasOwner }) } as unknown as Response; + }), + ); +} + +beforeEach(() => { + auth = { isAuthenticated: false, isLoading: false, user: null }; + apps = MULTI_APP; + stubBootstrapStatus(true); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** + * Only combinations the hook can actually produce are asserted. `useBootstrapStatus` + * is gated on `!isLoading && !isAuthenticated`, so `status` is necessarily + * `unknown` while auth is loading and for any visitor who arrived with a + * session — pairing `fresh` with either would be a phantom check. + */ +describe('decideSetupEntry — the policy', () => { + const settled = { isAuthenticated: false, isLoading: false }; + + it('decides nothing while auth is still settling', () => { + expect(decideSetupEntry('unknown', { isAuthenticated: false, isLoading: true })).toBe('pending'); + }); + + it('a live session proves an owner exists — no waiting on the probe', () => { + // The short-circuit is the point: `hasOwner: false` cannot be true while + // somebody is signed in, so the common case never pays for the round-trip. + expect(decideSetupEntry('unknown', { isAuthenticated: true, isLoading: false })).toBe('settings'); + }); + + it('waits for the probe when there is no session to decide from', () => { + expect(decideSetupEntry('unknown', settled)).toBe('pending'); + }); + + it('routes an un-bootstrapped deployment to the first-run wizard', () => { + expect(decideSetupEntry('fresh', settled)).toBe('first-run'); + }); + + it('routes a bootstrapped deployment to the settings deep link', () => { + expect(decideSetupEntry('bootstrapped', settled)).toBe('settings'); + }); + + it('a `fresh` verdict outranks a session — that ordering IS the wizard guard', () => { + // Unreachable on ARRIVAL (a visitor with a session never probes), but + // reachable mid-wizard: `signUp()` flips the session while `fresh` is + // already known. The ordering is what keeps the wizard mounted; without it + // this returns 'settings' and the in-flight org rename dies. + expect(decideSetupEntry('fresh', { isAuthenticated: true, isLoading: false })).toBe('first-run'); + }); +}); + +describe('useSetupEntryMode — the verdict survives the session flip', () => { + it('THE REGRESSION GUARD: signUp() flipping the session does not evict the wizard', async () => { + // `SetupPage` renames the bootstrap organization AFTER `signUp()` has made + // the visitor authenticated. Re-deciding on that flip would unmount the + // wizard mid-submission and kill the in-flight rename — the failure + // SetupPage's own "not mid-submission" guard was written for. + stubBootstrapStatus(false); + const { result, rerender } = renderHook(() => useSetupEntryMode()); + await waitFor(() => expect(result.current).toBe('first-run')); + + await act(async () => { + auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } }; + }); + rerender(); + expect(result.current).toBe('first-run'); + }); + + it('a failed bootstrap probe falls open to the wizard, not to a dead end', async () => { + // Matching SetupPage's own `catch`: showing the wizard on a bootstrapped + // deployment is recoverable; hiding it on a fresh one leaves no account to + // sign in with. + stubBootstrapStatus('network-error'); + const { result } = renderHook(() => useSetupEntryMode()); + await waitFor(() => expect(result.current).toBe('first-run')); + }); + + it('an authenticated visitor never probes, so cannot inherit that fall-open', async () => { + // The gate is what keeps a network hiccup from re-creating the bounce this + // card fixes: no probe means no `fresh`, so the session decides alone. + stubBootstrapStatus('network-error'); + auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } }; + const { result } = renderHook(() => useSetupEntryMode()); + await waitFor(() => expect(result.current).toBe('settings')); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); +}); + +describe('/setup deep link — the routed chain', () => { + it('THE FIX: an authenticated deep link lands on the Setup app, never on home', async () => { + auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } }; + renderSetupDeepLink(); + expect(await screen.findByTestId('app-subtree')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/com.objectstack.setup'); + expect(screen.queryByTestId('home-launcher')).not.toBeInTheDocument(); + }); + + it('THE FIX: the unauthenticated deep link carries a redirect back to /setup', async () => { + // `LoginRedirect` is the host's real contract and builds the param off the + // ROUTER's location, so it stays correct under a `` basename + // (objectui#4168) — window-derived paths would carry the mount into it. + auth = { isAuthenticated: false, isLoading: false, user: null }; + renderSetupDeepLink(); + expect(await screen.findByTestId('login-page')).toBeInTheDocument(); + expect(pathname()).toBe('/login?redirect=%2Fsetup'); + }); + + it('a deployment with no owner yet still gets the first-run wizard at /setup', async () => { + stubBootstrapStatus(false); + renderSetupDeepLink(); + expect(await screen.findByTestId('first-run-wizard')).toBeInTheDocument(); + // and it stays there — the wizard is the page, not a waypoint. + expect(pathname()).toBe('/setup'); + }); + + it('renders the loading fallback rather than guessing while auth settles', () => { + auth = { isAuthenticated: false, isLoading: true, user: null }; + renderSetupDeepLink(); + expect(screen.getByTestId('loading')).toBeInTheDocument(); + expect(pathname()).toBe('/setup'); + }); + + 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 + // AppContent answers with its own missing-app screen. + auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } }; + apps = [{ name: 'crm' }]; + renderSetupDeepLink(); + expect(await screen.findByTestId('app-subtree')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/com.objectstack.setup'); + expect(screen.queryByTestId('home-launcher')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/console/src/components/SetupRoute.tsx b/apps/console/src/components/SetupRoute.tsx new file mode 100644 index 0000000000..bbe89c2bea --- /dev/null +++ b/apps/console/src/components/SetupRoute.tsx @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/setup` — one URL, two surfaces (objectui#2794). + * + * The policy, and why the verdict is latched, live in `./setupEntry`. This file + * is only the mapping from verdict to element: + * + * - a deployment with no owner yet → the first-run bootstrap wizard, exactly + * as before this card; + * - every other deployment → the stable platform-administration deep link. It + * goes through `ProtectedRoute`, so an unauthenticated visitor gets this + * host's ONE auth-redirect contract (`/login?redirect=%2Fsetup`, built by + * `LoginRedirect` from the ROUTER's location) and lands back here after + * signing in. `requireOrganization={false}` for the same reason `/` uses it: + * this route only redirects, so the org gate belongs to the destination. + */ + +import { SetupRedirect, LoadingFallback } from '@object-ui/app-shell'; + +import { ProtectedRoute } from './ProtectedRoute'; +import { useSetupEntryMode } from './setupEntry'; +import { SetupPage } from '../pages/auth/SetupPage'; + +export function SetupRoute() { + const mode = useSetupEntryMode(); + if (mode === 'pending') return ; + if (mode === 'first-run') return ; + return ( + + + + ); +} diff --git a/apps/console/src/components/setupEntry.ts b/apps/console/src/components/setupEntry.ts new file mode 100644 index 0000000000..52ac7aca32 --- /dev/null +++ b/apps/console/src/components/setupEntry.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/setup` entry policy — which of the route's TWO meanings applies + * (objectui#2794). + * + * ## The collision this resolves + * + * `/setup` was already taken, by the first-run owner-bootstrap wizard + * (`pages/auth/SetupPage`, ported from the retired Account SPA). That page is + * public and meaningful only while the deployment has no owner; it bounces + * every other visitor away — a signed-in one with `window.location.assign('/')`, + * which `RootLandingRedirect` then resolves to `/home` on any multi-app + * deployment. THAT is the "深链被重定向回 home" the card measured: the redirect + * was not a missing route, it was the wizard evicting an authenticated visitor. + * + * So `/setup` needs a discriminator rather than a second URL. The honest one is + * the one the wizard itself already uses — whether this deployment has an owner + * (`GET /api/v1/auth/bootstrap-status` → `hasOwner`) — because that is exactly + * the condition under which the wizard is the right page. It is also monotonic: + * a deployment crosses it once, forever. + * + * ## Why a live session short-circuits the probe + * + * `hasOwner: false` cannot be true while somebody is signed in, so an + * authenticated visitor is decided without waiting for (or trusting) the probe. + * That keeps the common case off the network round-trip, and it keeps a FAILED + * probe — which falls open to "fresh", the wizard, matching `SetupPage`'s own + * `catch` — from re-creating the very bounce-to-home this card is about. + * + * ## Why a `fresh` verdict outranks everything, including the session + * + * `signUp()` flips the session to authenticated while the wizard is still + * running (it renames the bootstrap organization after the account exists). If + * the route re-decided on that flip it would unmount the wizard mid-submission + * and kill the in-flight rename — the exact failure `SetupPage`'s own + * "not mid-submission" guard was written for. + * + * No latch is needed to prevent that, because the probe is GATED on being + * unauthenticated: `fresh` can only ever be produced by a probe that ran while + * nobody was signed in, and `useBootstrapStatus` keeps its answer for the life + * of the mount. So `fresh` is already durable, and reading it first is what + * makes the verdict immune to the session flip. The same gate is why the two + * inputs never contradict each other in practice — see the reachability note on + * {@link decideSetupEntry}. + */ + +import { useEffect, useState } from 'react'; +import { useAuth } from '@object-ui/auth'; + +const AUTH_BASE = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`; + +/** + * Deployment bootstrap state. `fresh` is also what a FAILED probe reports — + * same fall-open as `SetupPage`'s own `catch`: showing the wizard on an + * already-bootstrapped deployment is recoverable (the wizard re-probes and + * bounces to login), whereas hiding it on a genuinely fresh one is a dead end, + * because no account exists to log in with. + */ +export type BootstrapStatus = 'unknown' | 'fresh' | 'bootstrapped'; + +/** Which surface `/setup` resolves to. */ +export type SetupEntryMode = + /** Inputs not settled yet — render the loading fallback, decide nothing. */ + | 'pending' + /** No owner yet: `/setup` is the owner-bootstrap wizard. */ + | 'first-run' + /** Normal deployment: `/setup` is the platform-administration deep link. */ + | 'settings'; + +/** The auth facts this decision reads. */ +export interface SetupEntryAuth { + isAuthenticated: boolean; + isLoading: boolean; +} + +/** + * The pure decision. Order matters and is the whole policy: + * 1. `fresh` wins outright — it can only come from a probe that ran while + * unauthenticated, so it survives `signUp()`'s session flip (see the file + * header) and the wizard is never unmounted from under itself; + * 2. nothing else is decided while auth is still settling; + * 3. a live session PROVES an owner exists → the settings deep link, without + * waiting on the bootstrap probe at all; + * 4. otherwise the probe decides, and only once it has an answer. + * + * REACHABILITY: `useBootstrapStatus` is gated on `!isLoading && !isAuthenticated`, + * so `status` is always `unknown` while auth is loading and for any visitor who + * arrived with a session. Combinations outside that are unreachable through the + * hook and are not what the ordering above is tuned for. + */ +export function decideSetupEntry( + status: BootstrapStatus, + auth: SetupEntryAuth, +): SetupEntryMode { + if (status === 'fresh') return 'first-run'; + if (auth.isLoading) return 'pending'; + if (auth.isAuthenticated) return 'settings'; + if (status === 'unknown') return 'pending'; + return 'settings'; +} + +/** + * Probe `hasOwner` once. `enabled` is load-bearing twice over: an + * already-authenticated visitor never pays for a request whose answer + * {@link decideSetupEntry} would ignore, AND it is what confines a `fresh` + * verdict to sessions-less visits, which is what makes that verdict durable. + * The answer is kept once received — losing `enabled` (as `signUp()` does) + * cancels the in-flight probe, it does not reset the state. + */ +export function useBootstrapStatus(enabled: boolean): BootstrapStatus { + const [status, setStatus] = useState('unknown'); + useEffect(() => { + if (!enabled) return; + let cancelled = false; + void (async () => { + try { + const res = await fetch(`${AUTH_BASE}/bootstrap-status`, { credentials: 'include' }); + const data: { hasOwner?: boolean } = res.ok ? await res.json().catch(() => ({})) : {}; + if (!cancelled) setStatus(data.hasOwner === true ? 'bootstrapped' : 'fresh'); + } catch { + // Fall open to the wizard — see BootstrapStatus. + if (!cancelled) setStatus('fresh'); + } + })(); + return () => { + cancelled = true; + }; + }, [enabled]); + return status; +} + +/** The verdict for this mount. */ +export function useSetupEntryMode(): SetupEntryMode { + const { isAuthenticated, isLoading } = useAuth(); + const status = useBootstrapStatus(!isLoading && !isAuthenticated); + return decideSetupEntry(status, { isAuthenticated, isLoading }); +} diff --git a/packages/app-shell/src/console/ConsoleShell.tsx b/packages/app-shell/src/console/ConsoleShell.tsx index 3bab0dc1cf..a5a468a662 100644 --- a/packages/app-shell/src/console/ConsoleShell.tsx +++ b/packages/app-shell/src/console/ConsoleShell.tsx @@ -23,6 +23,7 @@ import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack'; import { AdapterProvider, useAdapter } from '../providers/AdapterProvider'; import { withSettleSignal } from '../observability/settleSignal'; import { MetadataProvider, useMetadata } from '../providers/MetadataProvider'; +import { appRouteSegment } from '../utils/appRoute'; import { useAiSurfaceEnabled } from '../hooks/useAiSurface'; import { PreviewModeProvider } from '../preview/PreviewModeContext'; import { NavigationProvider } from '../context/NavigationContext'; @@ -406,3 +407,85 @@ export function SystemRedirect() { const target = suffix ? `/apps/setup/system${suffix}` : '/apps/setup/system'; return ; } + +/** + * The Setup app's canonical package id (ADR-0048 — one app per package). + * `@objectstack/setup` declares it, and `/apps/` is the app's + * canonical URL (see `utils/appRoute.ts`). + */ +export const SETUP_APP_PACKAGE_ID = 'com.objectstack.setup'; + +/** + * The Setup app's metadata `name`. `matchAppBySegment()` treats the name as a + * per-tenant friendly alias, so a deployment that registers Setup without an + * owning package (runtime/DB apps, older bundles) is still resolvable by it. + */ +export const SETUP_APP_NAME = 'setup'; + +/** + * Minimal shape this resolver needs off each App metadata record. Structurally + * identical to `appRoute.ts`'s `AppLike` — including the index signature, so an + * app read out of metadata stays assignable to `appRouteSegment()`. + */ +type SetupAppLike = { name?: unknown; _packageId?: unknown } & Record; + +/** + * The path `/setup` should forward to, resolved purely from the App list. + * Extracted from the component so the policy is unit-testable without a + * router/render — the same shape `resolveLandingPath` uses for `/`. + * + * The target is the setup app's ROOT (`/apps/`), never a hardcoded + * page inside it: `AppContent.resolveLandingRoute()` already resolves an app + * root to that app's first reachable navigation item, which for Setup is its + * System Overview dashboard. Naming that dashboard here would fork the + * "where does an app open" policy into a second place and let this alias drift + * the moment Setup's nav is re-ordered. + * + * The segment is built by `appRouteSegment()` — the SAME helper the home + * launcher's app cards use (`console/home/AppCard.tsx`), so the alias lands on + * byte-identical URLs to clicking the 「系统设置」 card. + * + * ABSENT SETUP APP (a stripped deployment, a viewer without `setup.access` — + * the app is server-filtered out of their metadata, or a publish still + * landing): the fallback is the canonical package-id URL, NOT home and NOT the + * bare `/apps/setup`. That is deliberate on both counts: + * - home is the defect this alias exists to fix (objectui#2794) — a deep link + * that silently lands somewhere else is indistinguishable from a broken one; + * - `/apps/setup` is `AppContent`'s `isSetupRoute` pseudo-route, which falls + * back to the DEFAULT app, i.e. it would silently render a different app. + * `/apps/com.objectstack.setup` matches no pseudo-route, so `AppContent`'s own + * `requestedAppMissing` branch handles it — the "App not available" screen with + * a Retry, exactly what typing any other missing app's URL produces today. It + * also self-heals: that branch re-checks metadata once before concluding. + */ +export function resolveSetupAppPath(apps: readonly SetupAppLike[] | null | undefined): string { + const list = apps ?? []; + const setupApp = + list.find((a) => a?._packageId === SETUP_APP_PACKAGE_ID) ?? + list.find((a) => a?.name === SETUP_APP_NAME); + return `/apps/${appRouteSegment(setupApp) ?? SETUP_APP_PACKAGE_ID}`; +} + +/** + * SetupRedirect — the stable `/setup` deep link for platform administration + * (objectui#2794). System settings had no direct URL: it was reachable only by + * clicking the home launcher's 「系统设置」 card, so it could not be bookmarked, + * shared, or named in a support/runbook instruction. + * + * Same shape as {@link SystemRedirect} beside it — resolve a target, then one + * ``; search and hash carry over. The difference is that this + * target is READ FROM METADATA rather than spelled out, because the setup app's + * canonical address is whatever `appRouteSegment()` builds for it (ADR-0048 + * keys `/apps/` on the package id, falling back to the app name). + * + * Mount it behind the host's authenticated route wrapper, so an unauthenticated + * deep link goes to login with a redirect back to `/setup` under the host's + * existing auth-redirect contract rather than a second spelling of it. + */ +export function SetupRedirect() { + const { apps, loading } = useMetadata(); + const location = useLocation(); + if (loading) return ; + const target = resolveSetupAppPath(apps as SetupAppLike[] | undefined); + return ; +} diff --git a/packages/app-shell/src/console/__tests__/setupRedirectTarget.test.tsx b/packages/app-shell/src/console/__tests__/setupRedirectTarget.test.tsx new file mode 100644 index 0000000000..3d3b02a3f6 --- /dev/null +++ b/packages/app-shell/src/console/__tests__/setupRedirectTarget.test.tsx @@ -0,0 +1,161 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `SetupRedirect` — the stable `/setup` deep link into platform administration + * (objectui#2794). + * + * ## The defect + * + * System settings had no direct URL. `/_console/setup` bounced the visitor back + * to `/_console/home`, so the entry point could only be reached by clicking the + * home launcher's 「系统设置」 card: nothing to bookmark, nothing to paste into a + * runbook, and asymmetric with Studio, which does have a stable front door. + * + * ## What the target is, and why it is not spelled out + * + * The card recorded the address it landed on as + * `/apps/com.objectstack.setup/dashboard/system_overview`. Only the FIRST + * segment pair of that is a fact about Setup; the rest is a fact about Setup's + * navigation on the day it was measured. `resolveSetupAppPath` therefore + * resolves the app ROOT — `/apps/` — and lets `AppContent`'s existing + * landing resolution (first reachable navigation item) pick the page, which is + * how every other app root already opens and how the home launcher's card + * already navigates. Re-spelling `dashboard/system_overview` here would fork + * that policy and start drifting the next time Setup's nav is re-ordered. + * + * The segment itself comes from `appRouteSegment()` — package id under ADR-0048, + * app name as the per-tenant fallback — so this alias and `home/AppCard.tsx` + * cannot disagree about where Setup lives. + * + * ## The absent-app case is the interesting one + * + * "Setup is missing from metadata" is reachable in normal operation, not just on + * a stripped build: `SETUP_APP.requiredPermissions = ['setup.access']`, so a + * viewer without it is served metadata with no Setup app at all. Falling back to + * `/home` there would re-create the exact defect this component fixes, and + * falling back to the bare `/apps/setup` would be worse than it looks — that URL + * is `AppContent`'s `isSetupRoute` pseudo-route, which resolves to the DEFAULT + * app, i.e. it would silently render some other app. The fallback is the + * canonical package-id URL, which matches no pseudo-route and therefore lands in + * `AppContent`'s own `requestedAppMissing` branch — the same "App not available" + * screen any other missing app produces. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; + +/** Swapped per test — the App metadata the shell has loaded. */ +let apps: unknown[] = []; +let loading = false; + +vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => ({ apps, objects: [], loading, error: null, refresh: async () => {} }), +})); + +import { + SetupRedirect, + resolveSetupAppPath, + SETUP_APP_PACKAGE_ID, + SETUP_APP_NAME, +} from '../ConsoleShell'; + +/** The Setup app as a stock deployment ships it (ADR-0048 one-app package). */ +const SETUP_APP = { name: SETUP_APP_NAME, label: 'Setup', _packageId: SETUP_APP_PACKAGE_ID }; + +/** Reports where the redirect actually landed, including search and hash. */ +function Landing() { + const { pathname, search, hash } = useLocation(); + return
{`${pathname}${search}${hash}`}
; +} + +function landedFrom(entry: string): string { + render( + + + } /> + } /> + + , + ); + return screen.getByTestId('landing').textContent ?? ''; +} + +describe('resolveSetupAppPath — the policy, without a router', () => { + it('THE FIX: a stock deployment resolves the Setup app root, never /home', () => { + const target = resolveSetupAppPath([{ name: 'crm' }, SETUP_APP, { name: 'showcase' }]); + expect(target).toBe('/apps/com.objectstack.setup'); + expect(target).not.toBe('/home'); + }); + + it('keys the segment on the package id — the same address AppCard builds', () => { + // Two vendors' apps may share a display name; ADR-0048 makes the package id + // the route key, and `appRouteSegment()` is the one place that decides it. + expect(resolveSetupAppPath([SETUP_APP])).toBe(`/apps/${SETUP_APP_PACKAGE_ID}`); + }); + + it('falls back to the app NAME for a Setup app with no owning package', () => { + // Runtime/DB-authored apps carry no `_packageId`; `matchAppBySegment` treats + // the name as a friendly alias, so the alias must too. + expect(resolveSetupAppPath([{ name: SETUP_APP_NAME, label: 'Setup' }])).toBe('/apps/setup'); + }); + + it('prefers the package-id match over a same-named impostor', () => { + const impostor = { name: SETUP_APP_NAME, _packageId: 'com.acme.setup' }; + expect(resolveSetupAppPath([impostor, SETUP_APP])).toBe(`/apps/${SETUP_APP_PACKAGE_ID}`); + }); + + it('ABSENT SETUP APP: falls back to the canonical package-id URL — not /home', () => { + // Reachable without a broken build: a viewer lacking `setup.access` is + // served metadata with no Setup app. + for (const list of [[], [{ name: 'crm' }], null, undefined]) { + const target = resolveSetupAppPath(list as never); + expect(target).toBe(`/apps/${SETUP_APP_PACKAGE_ID}`); + expect(target).not.toBe('/home'); + } + }); + + it('ABSENT SETUP APP: the fallback is NOT the bare /apps/setup pseudo-route', () => { + // `/apps/setup` is `AppContent`'s `isSetupRoute`, which falls back to the + // DEFAULT app — a silently wrong app instead of an honest "not available". + expect(resolveSetupAppPath([{ name: 'crm', isDefault: true }])).not.toBe('/apps/setup'); + }); +}); + +describe('SetupRedirect — the routed component', () => { + beforeEach(() => { + apps = []; + loading = false; + }); + + it('THE FIX: /setup lands on the Setup app, not back on home', () => { + apps = [{ name: 'crm' }, SETUP_APP]; + expect(landedFrom('/setup')).toBe('/apps/com.objectstack.setup'); + }); + + it('holds the redirect while metadata is still loading', () => { + // Deciding off an empty-because-unloaded app list would send a legitimate + // deep link to the "App not available" screen on every cold load. + loading = true; + apps = []; + render( + + + } /> + } /> + + , + ); + expect(screen.queryByTestId('landing')).not.toBeInTheDocument(); + }); + + it('preserves search and hash across the hop, like SystemRedirect beside it', () => { + apps = [SETUP_APP]; + expect(landedFrom('/setup?tab=general#audit')).toBe( + '/apps/com.objectstack.setup?tab=general#audit', + ); + }); +}); diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index ed72a151b7..d4730bb434 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -59,6 +59,12 @@ export { AuthenticatedRoute, RootRedirect, SystemRedirect, + // objectui#2794 — the stable `/setup` deep link into platform administration, + // plus the pure policy behind it and the identifiers it resolves by. + SetupRedirect, + resolveSetupAppPath, + SETUP_APP_PACKAGE_ID, + SETUP_APP_NAME, LoadingFallback, } from './console/ConsoleShell';