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
9 changes: 9 additions & 0 deletions .changeset/12706-setup-route-access-verdict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/app-shell': patch
---

A caller the Setup app refuses gets the access-denied screen on Setup's item pages instead of those pages under another app's chrome (objectui#12706).

A member without `setup.access` who opened `/apps/setup` landed on the default app's landing (the CRM dashboard, say), and `/apps/setup/metadata/...` drew the permission-set list and the permission-set and position editors under that app's chrome. The server refused every read that mattered, so nothing leaked, but the screen was wrong. Setup's root and the metadata-admin routes (with the legacy `component/metadata/...` spellings that forward onto them) now wait on the access verdict for the app the URL names, the same by-name probe an ordinary app's routes already wait on, and draw "You don't have access to this app" when the server refuses, with nothing of the page mounted. An admitted caller sees no change, and every verdict other than a refusal renders the page as before. Setup's `system/*` pages and the record pages under the Setup segment are not gated: a member reaches the approvals inbox there, and the records it links to.

The permission-set editor's Assigned Users section no longer draws a refused holders read as "0" and "No users assigned yet": it says the refusal in the server's words, and the empty copy follows only a read that succeeded. A refused Remove is said the same way instead of being swallowed. The position editor's holders section says a refused or failed holders read the same way, with no count, instead of drawing the refusal above "Nobody holds this position yet."
15 changes: 14 additions & 1 deletion packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,20 @@ family for them: it uses the same metadata-admin list and editors at
read-only on both assignment objects (objectstack's own pin for that is the
test `organization_admin is read-only on global RBAC tables to prevent
privilege escalation`), so that administrator sees the holders with no Add and
no Remove.
no Remove. When the server refuses the holders read itself, each section
says the refusal in the server's words and draws neither its count nor its
empty copy: the empty copy follows only a read that succeeded and came back
empty (objectui#12706).
- **Who gets these pages at all.** A caller the Setup app refuses (no
`setup.access`; the server leaves Setup out of that caller's app list) gets
the console's "You don't have access to this app" screen on these pages and
on Setup's root, the screen an ordinary app's routes already draw. The
metadata-admin routes and Setup's root wait on the access verdict for the app
the URL names (the by-name probe) before anything of the page mounts; every
verdict other than a refusal renders the page as before (objectui#12706).
Setup's `system/*` pages and the record pages under the Setup segment do not
wait on it: a member reaches the approvals inbox there, from the server's
`/system/approvals` links, and the records the inbox opens.

The list has an active switch and an active/inactive filter, through
`catalog-activation.ts`. Both show the activation ledger's state
Expand Down
126 changes: 84 additions & 42 deletions packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,31 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
// excluded so they keep their default-app fallback.
const requestedAppMissing = !!appName && !matchedApp && !isSpecialRoute;

// objectui#12706 — the pseudo-routes above skip `requestedAppMissing`, so
// until this the access verdict was never asked for them: a session the Setup
// app refuses (no `setup.access`; the server leaves Setup out of its list)
// opened `/apps/setup` and got the DEFAULT app's landing under that app's
// chrome, and `/apps/setup/metadata/...` drew Setup's item pages (the
// permission-set and position editors) the same way.
//
// Two of them now wait on the verdict for the app the URL names (the same
// probe as `requestedAppMissing` below) and draw the access-denied screen on
// `denied`: the metadata-admin routes (`metadata/...`, with the legacy
// spellings that redirect onto them) and Setup's root, the app's own landing.
// Every other verdict keeps the pseudo-route rendering above, so an app that
// does not exist, a server that does not answer and an adapter without the
// probe change nothing.
//
// ⛔ Not the rest of the Setup subtree. `/system/*` links forward onto
// `/apps/setup/system/*` (`SystemRedirect`, `ConsoleShell.tsx`), the server's
// approval links (`/system/approvals?request=`) among them, and that inbox
// opens records at `/apps/setup/OBJECT/record/ID` (`ApprovalsInboxPage`'s
// `recordHref`). A member reaches both under the Setup segment, so whether
// they wait on Setup's verdict is a product decision, left open on
// objectui#12706.
const isSetupRootRoute = location.pathname === '/apps/setup' || location.pathname === '/apps/setup/';
const appSurfaceAwaitsVerdict = !!appName && !matchedApp && (isMetadataRoute || isSetupRootRoute);

// 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
// concluding it's absent — so a just-built app resolves on its own instead
Expand Down Expand Up @@ -377,8 +402,13 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
// costing one skipped refresh rather than a wrong screen, which is why it was
// filed separately; objectui#4522 keyed it the same way.)
const [accessProbe, setAccessProbe] = useState<{ app: string; verdict: AppAccessVerdict } | null>(null);
// A missing app is asked once its re-check has settled; an app surface under
// a pseudo-route (objectui#12706, above) once the list has loaded, since a
// list still loading has matched nothing yet.
const verdictWanted =
(requestedAppMissing && missingRecheck === 'done') || (appSurfaceAwaitsVerdict && !metadataLoading);
useEffect(() => {
if (!requestedAppMissing || previewDrafts || missingRecheck !== 'done' || !appName) {
if (!verdictWanted || previewDrafts || !appName) {
// Cleared on the way out too, so the Retry button below (which returns
// `missingRecheck` to 'idle') re-asks instead of replaying an answer.
setAccessProbe(p => (p === null ? p : null));
Expand All @@ -403,7 +433,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
// (which now means nothing was asked at all) — objectui#9262.
.catch(() => { if (!cancelled) setAccessProbe({ app: probed, verdict: 'unreachable' }); });
return () => { cancelled = true; };
}, [requestedAppMissing, previewDrafts, missingRecheck, appName, accessProbe, dataSource]);
}, [verdictWanted, previewDrafts, appName, accessProbe, dataSource]);
// Never the previous app's answer: while a probe for a newly-requested app is
// in flight, this is null and the branch below waits rather than rendering the
// one it already has.
Expand Down Expand Up @@ -806,52 +836,64 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
// reachable with no active app: a fresh env where AI just drafted everything
// has ZERO published apps, and that is the surface you review & publish from.)

// objectui#4252 — the app EXISTS and this session may not open it. Say that,
// and say it as the permanent decision it is: the old copy named a transient
// deploy state, which sent a downstream acceptance round chasing a platform
// defect for two test batches over a missing permission-set binding.
//
// No Retry here — retrying a permission decision cannot change it, and a
// button that promises otherwise is the same misdirection one layer down.
// The way back is HOME instead, because this screen (like every no-app
// surface in this file) returns ABOVE the single `ConsoleLayout` mount and
// so carries no header, no navigation and no workspace switcher — the
// objectui#4473 strand, which a dead end here would recreate. Which home is
// the DECLARED one (objectui#7373): a control-plane customer bounced to the
// environment launcher lands among cards that act on an environment their
// deployment does not have. Router-relative for the same reason as the
// #4473 fix: `<Navigate>`/`navigate` resolve through the host's `basename`,
// and both the declared landing and the launcher fallback are part of the
// outer skeleton every host mounting this component provides (see this
// file's header).
//
// One screen for both branches that draw a `denied` verdict below.
const renderAppAccessDenied = () => (
<div className="h-screen flex items-center justify-center">
<Empty data-testid="app-access-denied">
<EmptyTitle>
{t('empty.appAccessDenied', { defaultValue: "You don't have access to this app" })}
</EmptyTitle>
<EmptyDescription>
{t('empty.appAccessDeniedDescription', {
defaultValue:
'This app exists, but your account is not authorized to open it. Ask an administrator to grant you access.',
})}
</EmptyDescription>
<div className="mt-4">
<Button onClick={() => navigate(homePath)} data-testid="app-access-denied-home">
{t('empty.appAccessDeniedHome', { defaultValue: 'Back to home' })}
</Button>
</div>
</Empty>
</div>
);

// objectui#12706 — an app surface under a pseudo-route, for an app the list
// does not carry (`appSurfaceAwaitsVerdict`, computed near the top). Nothing
// of it renders before the verdict, so a refused caller never mounts the
// page; `denied` is the screen above, and every other verdict falls through
// to the pseudo-route rendering below, unchanged.
if (appSurfaceAwaitsVerdict && !previewDrafts) {
if (accessVerdict === null) return <LoadingScreen />;
if (accessVerdict === 'denied') return renderAppAccessDenied();
}

// A normal app was requested but isn't in the loaded metadata. Re-check once
// for the post-publish lag, then say it's not available — NEVER render a
// different app, and don't show the misleading "no apps configured" screen
// below (there ARE apps). requestedAppMissing already excludes pseudo-routes.
if (requestedAppMissing) {
if (missingRecheck !== 'done' || accessVerdict === null) return <LoadingScreen />;
// objectui#4252 — the app EXISTS and this session may not open it. Say that,
// and say it as the permanent decision it is: the old copy named a transient
// deploy state, which sent a downstream acceptance round chasing a platform
// defect for two test batches over a missing permission-set binding.
//
// No Retry here — retrying a permission decision cannot change it, and a
// button that promises otherwise is the same misdirection one layer down.
// The way back is HOME instead, because this screen (like every no-app
// surface in this file) returns ABOVE the single `ConsoleLayout` mount and
// so carries no header, no navigation and no workspace switcher — the
// objectui#4473 strand, which a dead end here would recreate. Which home is
// the DECLARED one (objectui#7373): a control-plane customer bounced to the
// environment launcher lands among cards that act on an environment their
// deployment does not have. Router-relative for the same reason as the
// #4473 fix: `<Navigate>`/`navigate` resolve through the host's `basename`,
// and both the declared landing and the launcher fallback are part of the
// outer skeleton every host mounting this component provides (see this
// file's header).
if (accessVerdict === 'denied') {
return (
<div className="h-screen flex items-center justify-center">
<Empty data-testid="app-access-denied">
<EmptyTitle>
{t('empty.appAccessDenied', { defaultValue: "You don't have access to this app" })}
</EmptyTitle>
<EmptyDescription>
{t('empty.appAccessDeniedDescription', {
defaultValue:
'This app exists, but your account is not authorized to open it. Ask an administrator to grant you access.',
})}
</EmptyDescription>
<div className="mt-4">
<Button onClick={() => navigate(homePath)} data-testid="app-access-denied-home">
{t('empty.appAccessDeniedHome', { defaultValue: 'Back to home' })}
</Button>
</div>
</Empty>
</div>
);
}
if (accessVerdict === 'denied') return renderAppAccessDenied();
// objectui#9262 — the remaining verdicts, each saying only what the probe
// obtained. The screen this replaced asserted ONE transient deploy state
// ("it may still be publishing") over every one of them; that sentence is
Expand Down
Loading
Loading