From ead567614823d7c20d0219be7d3332c402dd83e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:08:10 +0000 Subject: [PATCH] fix(app-shell): Setup create form waits on the Setup app's access verdict A caller the Setup app refuses who opens /apps/setup/OBJECT/new now gets the access-denied screen the object list beside it draws, instead of the create form under the default app's shell. Record routes are unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jU9jKHFCJuQuuKRtpX --- .../29102-setup-create-route-verdict.md | 8 ++++ .../app-shell/src/console/AppContent.tsx | 40 +++++++++++------- ...tent.setupObjectListVerdict-28988.test.tsx | 42 +++++++++++++++++-- 3 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 .changeset/29102-setup-create-route-verdict.md diff --git a/.changeset/29102-setup-create-route-verdict.md b/.changeset/29102-setup-create-route-verdict.md new file mode 100644 index 00000000000..253d960d93d --- /dev/null +++ b/.changeset/29102-setup-create-route-verdict.md @@ -0,0 +1,8 @@ +--- +'@object-ui/app-shell': patch +--- + +Clause-②: no +Metadata-protocol: none + +A member the Setup app refuses who opens an object's create form under the Setup segment, `/apps/setup/OBJECT/new`, now gets the same "You don't have access to this app" screen as the object list beside it, instead of the create form inside the default app's header and sidebar. The package-id spelling `/apps/com.objectstack.setup/OBJECT/new` draws the same screen. Admins see the form as before, and the record pages the approvals inbox links to (`/apps/setup/OBJECT/record/ID`) are unchanged. diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 9430666f726..1b1da927362 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -202,24 +202,31 @@ function preloadAppRouteChunk(subPath: string, hostPaths: readonly string[] = [] } /** - * objectstack#28988 — the route paths that draw an object's LIST: the default - * list, a saved list view and the bare data surface. Record-scoped paths - * (`:objectName/record/...`, the `:objectName/:maybeRecordId` shorthand) and - * the create form are deliberately absent; see `appSurfaceAwaitsVerdict`. + * The object-scoped route paths that name no record: the default list, a saved + * list view, the bare data surface (objectstack#28988) and the create form + * (objectstack#29102). Record-scoped paths (`:objectName/record/...`, the + * `:objectName/:maybeRecordId` shorthand) are deliberately absent; see + * `appSurfaceAwaitsVerdict`. */ -const OBJECT_LIST_ROUTE_PATHS: ReadonlySet = new Set([':objectName', ':objectName/view/:viewId', ':objectName/data']); +const OBJECT_COLLECTION_ROUTE_PATHS: ReadonlySet = new Set([ + ':objectName', + ':objectName/view/:viewId', + ':objectName/data', + ':objectName/new', +]); /** - * Whether the route at `subPath` (the path below `/apps/:appName`) draws an - * object's list. Matched the way `` ranks paths, beside every path - * `AppContent` and the host declare, so a static route or a host route that - * shadows `:objectName` (`search`, `reports`, `system/...`) is not one. + * Whether the route at `subPath` (the path below `/apps/:appName`) is one of + * `OBJECT_COLLECTION_ROUTE_PATHS`. Matched the way `` ranks paths, + * beside every path `AppContent` and the host declare, so a static route or a + * host route that shadows `:objectName` (`search`, `reports`, `system/...`) is + * not one. */ -function isObjectListAppRoute(subPath: string, hostPaths: readonly string[] = []): boolean { +function isObjectCollectionAppRoute(subPath: string, hostPaths: readonly string[] = []): boolean { const routes = [...APP_ROUTE_METADATA_READS.map(({ path }) => ({ path })), ...hostPaths.map((path) => ({ path }))]; const matches = matchRoutes(routes, `/${subPath.replace(/^\/+/, '')}`); const path = matches?.[matches.length - 1].route.path; - return path !== undefined && OBJECT_LIST_ROUTE_PATHS.has(path); + return path !== undefined && OBJECT_COLLECTION_ROUTE_PATHS.has(path); } interface AppContentProps { @@ -579,16 +586,21 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // while the canonical `/apps/com.objectstack.setup/OBJECT`, an ordinary // unmatched app, already drew the refusal. The record routes the approvals // inbox links to stay outside this gate, as above; the list paths are - // matched by route rank (`isObjectListAppRoute`), so they never reach one. + // matched by route rank (`isObjectCollectionAppRoute`), so they never reach + // one. + // + // objectstack#29102 — and the create form beside them (`/apps/setup/OBJECT/ + // new`), which a member reached the same way. It names no record, so no + // inbox link lands on it. const isSetupRootRoute = location.pathname === '/apps/setup' || location.pathname === '/apps/setup/'; const [appSubRoot, appSubPage] = appSubPath.split('/'); const isSettingsOrMarketplaceRoute = appSubRoot === 'system' && (appSubPage === 'settings' || appSubPage === 'marketplace'); - const isSetupObjectListRoute = isSetupRoute && isObjectListAppRoute(appSubPath, hostRoutePaths); + const isSetupObjectCollectionRoute = isSetupRoute && isObjectCollectionAppRoute(appSubPath, hostRoutePaths); const appSurfaceAwaitsVerdict = !!appName && !matchedApp && - (isMetadataRoute || isSetupRootRoute || isSettingsOrMarketplaceRoute || isSetupObjectListRoute); + (isMetadataRoute || isSetupRootRoute || isSettingsOrMarketplaceRoute || isSetupObjectCollectionRoute); // Post-publish readiness: when a requested app isn't in the loaded metadata, // re-check ONCE (the registry can lag a beat behind a publish) before diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.setupObjectListVerdict-28988.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.setupObjectListVerdict-28988.test.tsx index 15760db8b60..76c165de75f 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.setupObjectListVerdict-28988.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.setupObjectListVerdict-28988.test.tsx @@ -22,7 +22,9 @@ * * The alias-spelled list routes (`OBJECT`, `OBJECT/view/ID`, `OBJECT/data`) * wait on the verdict for the app the URL names and draw `app-access-denied` - * on `denied`, nothing of the list mounted. An admitted caller and a server + * on `denied`, nothing of the list mounted. So does the create form beside + * them, `OBJECT/new` (objectstack#29102), in both spellings; an admitted + * caller still gets the form. An admitted caller and a server * without the app render as before. The record routes the approvals inbox * links to stay reachable (objectui#12706 leaves them open), as does the inbox * itself; `AppContent.setupItemRoutesVerdict-12706.test.tsx` pins both too. @@ -34,7 +36,7 @@ * real probe reading the real envelope. * * NOTE ON SCOPE: this file measures WHICH SURFACE renders. `ConsoleLayout` and - * the list, data and record pages are stubbed; each stub counts its mounts, so + * the list, data, record and create-form pages are stubbed; each stub counts its mounts, so * "nothing of the list mounted" is a count. */ @@ -70,6 +72,12 @@ vi.mock('../../views/ObjectDataPage', () => ({ return counted('object-data-page', objectName); }, })); +vi.mock('../../views/RecordFormPage', () => ({ + RecordFormPage: ({ mode }: { mode?: string }) => { + const { objectName } = useParams<{ objectName?: string }>(); + return counted('record-form-page', `${objectName}/${mode ?? ''}`); + }, +})); vi.mock('../../views/RecordDetailView', () => ({ RecordDetailView: () => { const { objectName, recordId } = useParams<{ objectName?: string; recordId?: string }>(); @@ -246,7 +254,7 @@ function renderConsoleAt(initialUrl: string) { const pathname = () => screen.getByTestId('pathname').textContent; /** Every page stub this file counts; a refused caller mounts none of them. */ -const PAGE_STUBS = ['object-view', 'object-data-page', 'record-detail-view', 'approvals-inbox-page']; +const PAGE_STUBS = ['object-view', 'object-data-page', 'record-form-page', 'record-detail-view', 'approvals-inbox-page']; const pagesMounted = () => PAGE_STUBS.filter((id) => (mounts[id] ?? 0) > 0); describe('AppContent — alias-spelled Setup object lists wait on the app\'s access verdict (objectstack#28988)', () => { @@ -298,6 +306,34 @@ describe('AppContent — alias-spelled Setup object lists wait on the app\'s acc expect(metaItemRequests[0]).toContain('/api/v1/meta/app/com.objectstack.setup'); }); + // objectstack#29102 — the create form names no record, so no inbox link + // lands on it; it waits on the verdict like the lists beside it. + it.each([ + ['the alias', '/apps/setup/sys_user/new', 'setup'], + ['an app object under the alias', '/apps/setup/showcase_task/new', 'setup'], + ['the package id', '/apps/com.objectstack.setup/sys_user/new', 'com.objectstack.setup'], + ])('a refused caller gets app-access-denied on the create form spelled with %s, never the form', async (_label, url, app) => { + renderConsoleAt(url); + + expect(await screen.findByTestId('app-access-denied')).toBeInTheDocument(); + expect(screen.queryByTestId('console-layout')).not.toBeInTheDocument(); + expect(pagesMounted()).toEqual([]); + expect(metaItemRequests).toHaveLength(1); + expect(metaItemRequests[0]).toContain(`/api/v1/meta/app/${app}`); + expect(pathname()).toBe(url); + }); + + it('CONTROL — an admitted caller gets the create form under Setup, and nothing is probed', async () => { + metadataApps = [SHOWCASE_APP, SETUP_APP]; + + renderConsoleAt('/apps/setup/sys_user/new'); + + expect(await screen.findByTestId('record-form-page')).toHaveTextContent('sys_user/create'); + expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'setup'); + expect(screen.queryByTestId('app-access-denied')).not.toBeInTheDocument(); + expect(metaItemRequests).toEqual([]); + }); + it('CONTROL — an admitted caller gets the list under Setup, and nothing is probed', async () => { metadataApps = [SHOWCASE_APP, SETUP_APP];