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
17 changes: 17 additions & 0 deletions .changeset/12079-empty-nav-landing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

An app that serves the signed-in user no navigation now opens on an app-level empty state, not on the Studio home (objectui#12079).

The server filters an app's navigation per user. When every group is gated on a capability the user does not hold, the app arrives with `navigation: []`. The console's app index route found no landing page and rendered `StudioHomePage`, Studio's metadata overview, inside an app that had nothing to do with Studio. This was measured on HotCLM for its dev admin (objectstack-ai/hotclm#87).

The app root now shows "Nothing in APP is available to you yet", with the app's own label. A user the console treats as a workspace administrator (`useWorkspaceAdminStatus`, the same check the console's no-app screen and Home's administration links use) gets an "Open Setup" link, at the URL the `/setup` deep link resolves. Everyone else is asked to contact an administrator.

Unchanged:

- An app with a landing page still opens on it.
- The Studio app still opens on its own home. Its navigation is full but holds only `component` items, which the landing resolver does not walk, so it reaches the same fallback with a non-empty navigation.

New `@object-ui/i18n` keys, in all ten language packs: `empty.appNothingAvailable`, `empty.appNothingAvailableDescription`, `empty.appNothingAvailableGrantDescription` and `empty.openSetup`.
32 changes: 26 additions & 6 deletions packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ import { KeyboardShortcutsDialog } from '../chrome/KeyboardShortcutsDialog.js';
import { OnboardingWalkthrough } from '../chrome/OnboardingWalkthrough.js';
import { RouteFader } from '../chrome/RouteFader.js';
import { NavigationSyncEffect } from '../hooks/useNavigationSync.js';
import { AppNoAccessEmptyState } from './AppNoAccessEmptyState.js';
import { appServesNoNavigation } from './appServesNoNavigation.js';
import { resolveSetupAppPath } from './ConsoleShell.js';

// Route-based code splitting — lazy-load less-frequently-used routes
const RecordDetailView = lazy(() => import('../views/RecordDetailView.js').then(m => ({ default: m.RecordDetailView })));
Expand Down Expand Up @@ -1075,13 +1078,30 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
<Route
path="/"
element={(() => {
// When the app declares a landing target (home page or
// first nav route) honour it; otherwise — e.g. the
// metadata-admin "Studio" app whose nav is built from
// domains and has no single landing — render the rich
// overview instead of a blank `<Navigate to="">`.
// When the app declares a landing target (first nav
// route) honour it.
const landing = resolveLandingRoute(activeApp, { currentUserId: user?.id ?? null });
return landing ? <Navigate to={landing} replace /> : <StudioHomePage />;
if (landing) return <Navigate to={landing} replace />;
// objectui#12079 — an app that serves this caller NO
// navigation (every group gated on a capability they do
// not hold) is an app they have no access in yet. Say so,
// and offer Setup to a caller who can grant access: the
// same `isWorkspaceAdmin` read the no-app empty state
// above gates its Setup button on, and the same Setup
// URL the `/setup` deep link resolves.
if (appServesNoNavigation(activeApp)) {
return (
<AppNoAccessEmptyState
app={activeApp}
setupPath={isWorkspaceAdmin ? resolveSetupAppPath(apps) : null}
/>
);
}
// A navigation with no landing: the metadata-admin
// "Studio" app, whose nav is built from `component`
// items the landing resolver does not walk. Its root is
// the rich overview, not a blank `<Navigate to="">`.
return <StudioHomePage />;
})()}
/>
{/* Metadata admin routes — declared BEFORE the generic
Expand Down
78 changes: 78 additions & 0 deletions packages/app-shell/src/console/AppNoAccessEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The app root's answer when the app serves this caller NO navigation
* (objectui#12079).
*
* The server filters an app's navigation per caller before it reaches the
* console (`requiredPermissions`, `requiresService` and the doc audience, with
* emptied groups dropped). An app whose every group is capability-gated
* therefore arrives as `navigation: []` for a caller who holds none of those
* capabilities. The sidebar is empty, and the app index route has no landing
* to resolve. Until objectui#12079 that route fell back to `StudioHomePage`,
* the Studio app's own overview, so the caller saw metadata counters and
* "New Object" quick actions inside an app that had nothing to do with Studio,
* and nothing told them what to do next.
*
* This is an app-level empty state instead. It names the app and says the
* caller has no access in it yet. A caller who can grant access gets a link to
* Setup; everyone else is told to ask an administrator.
*/

import { Link } from 'react-router-dom';
import { Lock } from 'lucide-react';
// The three primitives `RouteNotFound` and the app-verdict screens in
// `AppContent.tsx` already compose, so this screen looks like its siblings and
// adds nothing to the eager `ui-components` chunk.
import { Button, Empty, EmptyDescription, EmptyTitle } from '@object-ui/components';
import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
import { resolveKeyedI18nLabel } from '../utils/index.js';

export interface AppNoAccessEmptyStateProps {
/** The active app, as the console's metadata list carries it. */
app: { name: string; label?: string | { key: string; defaultValue?: string } };
/**
* Where Setup lives, for a caller who can grant access. `null` for everyone
* else, which hides the link and asks them to contact an administrator.
*/
setupPath: string | null;
}

export function AppNoAccessEmptyState({ app, setupPath }: AppNoAccessEmptyStateProps) {
const { t } = useObjectTranslation();
const { appLabel } = useObjectLabel();
// The same label the header's app switcher shows for this app.
const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) });

return (
<div className="flex h-full w-full items-center justify-center p-8" data-testid="app-no-access-empty-state">
<Empty>
<div className="flex size-10 items-center justify-center rounded-lg bg-muted text-muted-foreground" aria-hidden="true">
<Lock className="size-5" />
</div>
<EmptyTitle>
{t('empty.appNothingAvailable', {
app: label,
defaultValue: 'Nothing in {{app}} is available to you yet',
})}
</EmptyTitle>
<EmptyDescription>
{setupPath
? t('empty.appNothingAvailableGrantDescription', {
defaultValue: 'This app has no pages your account can open. You can grant access in Setup.',
})
: t('empty.appNothingAvailableDescription', {
defaultValue: 'This app has no pages your account can open. Ask an administrator to grant you access.',
})}
</EmptyDescription>
{setupPath && (
<Button asChild variant="outline">
<Link to={setupPath} data-testid="app-no-access-open-setup">
{t('empty.openSetup', { defaultValue: 'Open Setup' })}
</Link>
</Button>
)}
</Empty>
</div>
);
}
Loading
Loading