diff --git a/.changeset/26838-launcher-hidden-tabs.md b/.changeset/26838-launcher-hidden-tabs.md new file mode 100644 index 00000000000..b8706d0f3aa --- /dev/null +++ b/.changeset/26838-launcher-hidden-tabs.md @@ -0,0 +1,21 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/console': patch +--- + +fix(app-shell): Home, the app switcher and the `app:launcher` block no longer offer an app the user's permission sets hide + +Clause-②: no +Metadata-protocol: none + +A permission set's `tabPermissions: { APP: 'hidden' }` hides an app from the member's launcher. The server applies that hide in one place, `GET /api/v1/me/apps`, and the console never read it: Home's tiles, the app switcher's menu and the `app:launcher` page block all listed `GET /api/v1/meta/app`, which still serves the hidden app because a hidden app keeps opening from a direct URL. So the member saw, and could open, the tile their admin had hidden. + +What changes: + +- The console reads `GET /api/v1/me/apps` once per user, beside its `/auth/me/permissions` read, and every launcher surface narrows its `/meta/app` list by that answer's app names. The tiles keep the labels, icons and publish state `/meta/app` serves. +- An app the answer was not asked about is not hidden by it: an app that is installed or published after the read stays on the launcher, and the console asks again. +- Home waits for the first answer, as it waits for the app list, so a hidden tile is never drawn and then removed. +- A deployment without the security plugin answers `404` there; the launcher then lists every app the user can open, as before. Any other failure does the same and says so once on the browser console. +- A hidden app still opens from its URL, and the switcher still names it while the user is in it. `default_off` is not a hide and changes nothing. + +What to do: nothing. A permission set that already hides an app now hides it on Home, in the switcher and in launcher blocks. diff --git a/ui/apps/console/src/AppContent.tsx b/ui/apps/console/src/AppContent.tsx index 1e27d3ee523..be884c6ab79 100644 --- a/ui/apps/console/src/AppContent.tsx +++ b/ui/apps/console/src/AppContent.tsx @@ -11,7 +11,7 @@ import { lazy, Suspense, useMemo, type ReactNode } from 'react'; import { Route, useParams, useLocation, Navigate } from 'react-router-dom'; -import { DefaultAppContent, ENVIRONMENT_SCOPE_QUERY, LoadingScreen, useAdapter } from '@object-ui/app-shell'; +import { DefaultAppContent, ENVIRONMENT_SCOPE_QUERY, LauncherAppsProvider, LoadingScreen, useAdapter } from '@object-ui/app-shell'; import { AdapterCtx } from '@object-ui/react'; import { MePermissionsProvider, ServerReadScope } from '@object-ui/permissions'; import { createAuthenticatedFetch } from '@object-ui/auth'; @@ -406,7 +406,13 @@ export function ConsolePermissionsScope({ children }: { children: ReactNode }) { // error + retry affordance all along. errorFallback={(err, retry) => } > - {children} + {/* objectstack#26838 — the apps a launcher offers (Home's tiles, the app + switcher, the `app:launcher` block) are the `/me/apps` answer, which + drops an app the user's permission sets hide. One read for Home and + every app, so this common ancestor holds it, beside the answer above. */} + + {children} + ); } diff --git a/ui/packages/app-shell/src/chrome/CommandPalette.launcherHiddenApps-26838.test.tsx b/ui/packages/app-shell/src/chrome/CommandPalette.launcherHiddenApps-26838.test.tsx new file mode 100644 index 00000000000..055e586aa45 --- /dev/null +++ b/ui/packages/app-shell/src/chrome/CommandPalette.launcherHiddenApps-26838.test.tsx @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26838 — the ⌘K palette's "Switch app" group offers no app the + * user's permission sets hide (`tabPermissions: hidden`): it is narrowed by + * the same `GET /me/apps` answer as Home's tiles and the app switcher. The + * palette keeps its own rule otherwise — every active app, a `hidden` one + * (the avatar-menu kind) included. + * + * Under the real `LauncherAppsProvider` fed a `/me/apps` double, the real + * `CommandPaletteProvider` opened by its `?palette=1` deep link, and a real + * `I18nProvider` in `en` — the `CommandPalette.navMatch-11812` harness. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; + +const state = vi.hoisted(() => ({ apps: [] as Array> })); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1' }, activeOrganization: null }), +})); +// The provider reads the app list from the metadata cache; the rest of the +// cache is the real (empty) one the palette's other groups read. +vi.mock('../providers/MetadataProvider', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useMetadata: () => ({ ...actual.useMetadata(), apps: state.apps, loading: false }), + }; +}); + +import { CommandPalette } from './CommandPalette'; +import { CommandPaletteProvider } from '../context/CommandPaletteProvider'; +import { LauncherAppsProvider } from '../hooks/useLauncherApps'; + +const SHOWCASE = { name: 'showcase_app', label: 'Showcase', navigation: [] }; +const CRM = { name: 'crm_app', label: 'CRM', navigation: [] }; +const OPS = { name: 'ops_app', label: 'Ops', navigation: [] }; +const ACCOUNT = { name: 'account', label: 'Account', hidden: true, navigation: [] }; + +/** A `/me/apps` double: the listed names, or a bare status. */ +function meApps(answer: string[] | number) { + return vi.fn(async (_url: unknown) => + typeof answer === 'number' + ? new Response('{}', { status: answer }) + : new Response(JSON.stringify({ apps: answer.map((name) => ({ name })) }), { status: 200 }), + ); +} + +function mount(fetcher: ReturnType) { + render( + + + + + {}} + dataSource={{ find: vi.fn(async () => ({ data: [] })), searchAll: vi.fn(async () => ({ hits: [] })) }} + /> + + + + , + ); + if (!document.querySelector('[cmdk-input]')) throw new Error('the palette did not open'); +} + +/** The visible text of every app item cmdk rendered. */ +function shownApps(): string[] { + return Array.from(document.querySelectorAll('[cmdk-item][data-value^="app "]')).map((el) => + (el.textContent ?? '').replace(/Current$/, '').trim(), + ); +} + +beforeEach(() => { + state.apps = [SHOWCASE, CRM, OPS, ACCOUNT]; +}); + +afterEach(() => cleanup()); + +describe('objectstack#26838 — the palette offers no app a permission set hides', () => { + it('an app /me/apps does not list is not a "Switch app" item', async () => { + const fetcher = meApps(['showcase_app', 'crm_app', 'account']); + mount(fetcher); + await waitFor(() => expect(shownApps()).toEqual(['Showcase', 'CRM', 'Account'])); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('CONTROL — a 404 (no security plugin) offers every active app, as before', async () => { + const fetcher = meApps(404); + mount(fetcher); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(shownApps()).toEqual(['Showcase', 'CRM', 'Ops', 'Account'])); + }); +}); diff --git a/ui/packages/app-shell/src/chrome/CommandPalette.tsx b/ui/packages/app-shell/src/chrome/CommandPalette.tsx index 97fee375502..aa28ef52f33 100644 --- a/ui/packages/app-shell/src/chrome/CommandPalette.tsx +++ b/ui/packages/app-shell/src/chrome/CommandPalette.tsx @@ -73,6 +73,7 @@ import { fetchPackages, type PkgEntry } from '../views/studio-design/packages-io import { isPackageLessItem, studioOrgScopePath } from '../views/studio-design/studioScope.js'; import { DESIGNER_SURFACE_PARAM, formatSurfaceParam } from '../views/metadata-admin/nav-selection.js'; import { flattenNavigation, useAppReportEntries } from '../views/appReportIndex.js'; +import { useServedApps } from '../hooks/useLauncherApps.js'; /** The palette of the active app: the frame `ConsoleLayout` draws for `/apps/APP`. */ interface AppCommandPaletteProps { @@ -235,7 +236,10 @@ function CommandPaletteBody(props: CommandPaletteProps) { const appReports = useAppReportEntries(activeApp, open && !inStudio).entries.filter( (entry) => !navReportNames.has(entry.name) && matches(entry.label, entry.name), ); - const switchableApps = apps.filter((a) => a.active !== false); + // Every active app, `hidden` ones included, minus an app the user's + // permission sets hide: the palette offers none that Home's tiles and the + // switcher withhold for that reason (objectstack#26838). + const switchableApps = useServedApps(apps.filter((a) => a.active !== false)); const appItems = switchableApps.length > 1 ? switchableApps.filter((app) => matches(resolveKeyedI18nLabel(app.label, t), app.name)) diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 3d610b8dc92..dfd9bb29c91 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -33,6 +33,7 @@ import { import { buildExpressionUser } from '../providers/expressionUser.js'; import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent.js'; import { useHomePath } from '../hooks/useHomePath.js'; +import { useLauncherApps } from '../hooks/useLauncherApps.js'; import { useSignedInUserLocale } from '../hooks/useUserLocale.js'; import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js'; import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view'; @@ -336,8 +337,11 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // ⛔ `hidden` only — never `_unpublished` (objectstack#6955 / objectstack-ai/objectstack#4829 A1). The // publish gate is enforced server-side; duplicating it here would strand a // builder on their own unpublished app. - const activeApps = apps.filter((a: any) => a.active !== false); - const launcherApps = activeApps.filter((a: any) => a.hidden !== true); + // + // objectstack#26838 — and, like Home's tiles and the switcher, never on an + // app the user's permission sets hide (`tabPermissions: hidden`, the + // `/me/apps` answer); the route lookup below still finds such an app. + const { apps: launcherApps } = useLauncherApps(apps); // ADR-0048 (A) — the route segment is the package id; resolve by it, // falling back to the app name (legacy/alias URL). // Built-in pseudo-routes under /apps/* that are NOT metadata apps (create-app, diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.defaultLandingHiddenApp-26838.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.defaultLandingHiddenApp-26838.test.tsx new file mode 100644 index 00000000000..60dcb90c7bf --- /dev/null +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.defaultLandingHiddenApp-26838.test.tsx @@ -0,0 +1,215 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26838 — when a URL names no app the shell can resolve (a + * pseudo-route such as `/apps/ghost/system/marketplace`), `AppContent` falls + * back to the default launcher app. That fallback never lands on an app the + * user's permission sets hide (`tabPermissions: hidden`): its list is the one + * Home's tiles and the switcher offer, narrowed by the `GET /me/apps` answer. + * + * Under the real `LauncherAppsProvider` fed a `/me/apps` double; the routing + * harness (stubs, `ConsoleLayout` probe echoing `activeAppName`) is + * `AppContent.pseudoRouteSegments.test.tsx`'s. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { + MemoryRouter, + Routes, + Route, + Navigate, + useParams, +} from 'react-router-dom'; + +// --------------------------------------------------------------------------- +// Mocks — everything that takes part in the routing decision (AppContent's +// guards, its nested , react-router's matching) stays real. +// --------------------------------------------------------------------------- + +// AGENTS.md §测试纪律 — the lazy page modules are stubbed so no unbounded +// `import()` races a bounded `findBy` window. +vi.mock('../../views/metadata-admin', () => ({ + MetadataDirectoryPage: () =>
directory
, + MetadataResourceListPage: () => { + const { type } = useParams<{ type?: string }>(); + return
{type}
; + }, + MetadataResourceEditPage: () =>
, + MetadataResourceHistoryPage: () =>
, + MetadataDiagnosticsPage: () =>
, +})); + +vi.mock('../marketplace/MarketplacePage', () => ({ + MarketplacePage: () =>
marketplace
, +})); +vi.mock('../marketplace/MarketplacePackagePage', () => ({ + MarketplacePackagePage: () =>
, +})); + +vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({ + ...(await importOriginal()), + CreateAppPage: () =>
create app
, + EditAppPage: () =>
, + DashboardDesignPage: () =>
, +})); + +// Pay the real designer barrel's load in the IMPORT phase, where no timeout +// applies. `AppContent` reaches `CreateAppPage` only through +// `React.lazy(() => import('@object-ui/plugin-designer'))`, so the factory +// above -- which now awaits the real module -- first runs when that lazy +// boundary resolves, i.e. INSIDE the `findByTestId` budget of the two cases +// below that assert on `create-app-page`. Under a saturated transform pipeline +// the barrel's graph does not fit in that budget and the file goes red on LOAD +// rather than on behaviour (AGENTS.md, the flaky-test discipline: an unbounded +// module load counted against a bounded window). The specifier is byte- +// identical to the component's own, so ESM hands the lazy factory this +// already-resolved module. +import '@object-ui/plugin-designer'; + +/** + * The probe that makes the bug visible: it reports WHICH app's shell rendered. + * The real `ConsoleLayout` drags in the whole console chrome (sidebar, header, + * chat dock) — none of it part of this question. + */ +vi.mock('../../layout/ConsoleLayout', () => ({ + ConsoleLayout: ({ activeAppName, children }: { activeAppName?: string; children?: React.ReactNode }) => ( +
+ {children} +
+ ), +})); +vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null })); +vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null })); +vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null })); + +/** Echoes `:objectName` — the near-miss segment is taken as an object name. */ +vi.mock('../../views/ObjectView', () => ({ + ObjectView: () => { + const { objectName } = useParams<{ objectName?: string }>(); + return
{objectName}
; + }, +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), + useObjectLabel: () => ({ + objectLabel: ({ label }: { label?: string }) => label, + }), +})); + +// A workspace admin: the zero-app cases below land on the "no apps configured" +// empty state, which since objectui#4473 renders for admins only — a viewer who +// cannot act on its CTAs is bounced to `/home` with the shell chrome instead of +// being stranded there (`AppContent.inaccessibleAppStrand.test.tsx`). Which +// BRANCH a segment reaches — the question this file measures — does not depend +// on the viewer. +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + // A signed-in user: the `/me/apps` read is asked per user. + useAuth: () => ({ + user: { id: 'u1' }, + getAuthConfig: async () => ({ features: {} }), + activeOrganization: null, + }), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); + +const dataSourceStub = { + onConnectionStateChange: () => () => {}, + getConnectionState: () => 'connected', +}; +vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => dataSourceStub, +})); + +/** + * Two published apps, one of them the default. The default is what a broken + * `requestedAppMissing` falls back to, so `crm` is the app that must NEVER + * appear for a `/apps//…` URL. + */ +const APPS = [ + { name: 'crm', label: 'CRM', isDefault: true, navigation: [] }, + { name: 'sales', label: 'Sales', navigation: [] }, +]; + +const refreshMetadata = vi.fn(async () => {}); +let metadataApps: unknown[] = APPS; +vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => ({ + apps: metadataApps, + objects: [], + loading: false, + // `undefined` — no bucket preloading to await, so the shell is ready on + // first render (mirrors a host that ships metadata eagerly). + ensureType: undefined, + error: null, + refresh: refreshMetadata, + }), +})); + +import { AppContent } from '../AppContent'; +import { LauncherAppsProvider } from '../../hooks/useLauncherApps'; + +/** A `/me/apps` double: the listed names, or a bare status. */ +function meApps(answer: string[] | number) { + return vi.fn(async (_url: unknown) => + typeof answer === 'number' + ? new Response('{}', { status: answer }) + : new Response(JSON.stringify({ apps: answer.map((name) => ({ name })) }), { status: 200 }), + ); +} + +function renderAt(url: string, fetcher: ReturnType) { + render( + + + + } /> + home
} /> + } /> + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + metadataApps = APPS; +}); + +afterEach(() => cleanup()); + +describe('the default-app fallback skips an app a permission set hides (objectstack#26838)', () => { + it('the default app hidden by /me/apps: the fallback lands on the next launcher app', async () => { + const fetcher = meApps(['sales']); + renderAt('/apps/ghost/system/marketplace', fetcher); + expect(await screen.findByTestId('marketplace-page')).toBeInTheDocument(); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'sales'), + ); + }); + + it('CONTROL — a 404 (no security plugin) keeps the declared default', async () => { + const fetcher = meApps(404); + renderAt('/apps/ghost/system/marketplace', fetcher); + expect(await screen.findByTestId('marketplace-page')).toBeInTheDocument(); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'crm'); + }); + + it('a URL that names the hidden app still opens it', async () => { + renderAt('/apps/crm/system/marketplace', meApps(['sales'])); + expect(await screen.findByTestId('marketplace-page')).toBeInTheDocument(); + expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'crm'); + }); +}); diff --git a/ui/packages/app-shell/src/console/home/HomePage.tsx b/ui/packages/app-shell/src/console/home/HomePage.tsx index 56447d7d812..231e4b759b1 100644 --- a/ui/packages/app-shell/src/console/home/HomePage.tsx +++ b/ui/packages/app-shell/src/console/home/HomePage.tsx @@ -38,6 +38,7 @@ import { RecoveryPasswordReminder } from './RecoveryPasswordReminder.js'; import { HomeActionCenter, HomeContinue, HomeActivity, HomePendingDraftsRow, type RecentItemContext } from './HomeRail.js'; import { resolveI18nLabel } from '@objectstack/spec/ui'; import { useHomeInbox } from '../../hooks/useHomeInbox.js'; +import { useLauncherApps } from '../../hooks/useLauncherApps.js'; import { useNavigationContext } from '../../context/NavigationContext.js'; import { appRouteSegment, @@ -415,6 +416,12 @@ export function HomePage() { }); const activeApps = filterActiveApps(apps); + // The apps the tiles and the System Settings link offer: the active list + // narrowed by the user's `/me/apps` answer, so an app a permission set hides + // draws no tile (objectstack#26838). The other readings below — the host + // app, a recent row's app label — keep the full list: a hidden app still + // opens, so a link into it or a label for it is still true. + const launcher = useLauncherApps(apps); /** * Which app hosts the app-independent pages this page links into — the @@ -445,8 +452,8 @@ export function HomePage() { * user can open, exactly where its tile used to be drawn. */ const setupPath = resolveSetupAppPath(activeApps); - const setupApp = activeApps.find((app) => `/apps/${appRouteSegment(app)}` === setupPath); - const launcherApps = setupApp ? activeApps.filter((app) => app !== setupApp) : activeApps; + const setupApp = launcher.apps.find((app) => `/apps/${appRouteSegment(app)}` === setupPath); + const launcherApps = setupApp ? launcher.apps.filter((app) => app !== setupApp) : launcher.apps; // When each tile's app was last open — the recent-items store's newest entry // inside it (objectui#12601; the reading is `lastOpenedByApp`'s). const lastOpened = lastOpenedByApp(launcherApps, recentItems); @@ -527,8 +534,9 @@ export function HomePage() { // the verdict still in flight drops the admin entries and adds them back a // moment later. `isResolved` is true the instant `isAdmin` is, so an admin the // session already identifies waits nothing; this only holds the frame that - // would otherwise be built on a guess. - if (loading || !isAdminResolved) { + // would otherwise be built on a guess. The first `/me/apps` read joins it + // too, so a hidden app's tile is never drawn and then taken away. + if (loading || !isAdminResolved || launcher.pending) { return (
{t('home.loading', { defaultValue: 'Loading…' })}
diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.launcherHiddenApps-26838.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.launcherHiddenApps-26838.test.tsx new file mode 100644 index 00000000000..3c23c9f11b8 --- /dev/null +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.launcherHiddenApps-26838.test.tsx @@ -0,0 +1,228 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26838 — Home's tiles are the apps the user's `GET /me/apps` + * answer lists, so an app a permission set hides (`tabPermissions: hidden`) + * draws no tile, while `GET /meta/app` still serves it (it opens from a URL). + * + * On the real `HomePage` and `HomeAppsStrip`, under the real + * `LauncherAppsProvider` fed a `/me/apps` double: + * + * - `/meta/app` = [setup, crm, showcase_app], `/me/apps` = [setup, crm]: + * no Showcase tile; Setup's System Settings link stays; + * - a set that hides Setup takes the System Settings link away too; + * - `404` (no security plugin): every tile, as before; + * - while the first read is out the page holds its loading line and draws + * no tile, so a hidden tile is never drawn and then taken away. + * + * The harness is `HomePage.greetingAndApps-12601.test.tsx`'s. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, cleanup, act } from '@testing-library/react'; + +const navigateMock = vi.fn(); + +const state = vi.hoisted(() => ({ + isAdmin: true, + canAuthorMetadata: true, + marketplaceEnabled: true, + pendingApprovalsCount: 0, + approvalsStatus: 'ready' as 'idle' | 'loading' | 'ready' | 'error', + apps: [] as Array>, + recentItems: [] as Array>, +})); + +vi.mock('react-router-dom', async () => { + const { createElement } = await import('react'); + return { + useNavigate: () => navigateMock, + // The tiles and the settings link are router `Link`s; this harness has no + // router, so the stand-in renders the anchor `Link` would, minus the basename. + Link: ({ to, ...rest }: { to: string } & Record) => + createElement('a', { ...rest, href: to }), + }; +}); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, opts?: { count?: number; time?: string }) => + `«${key}»` + + (opts?.count === undefined ? '' : `[count=${opts.count}]`) + + (opts?.time === undefined ? '' : `[time=${opts.time}]`), + language: 'en', + }), + // Every labeller echoes the label, else the name: the tiles read `appLabel`, + // and the recent rail beside them labels the store's entries. + useObjectLabel: () => { + const label = (x: { name?: string; label?: string }) => String(x?.label || x?.name || ''); + return { + appLabel: label, + objectLabel: label, + objectPluralLabel: label, + dashboardLabel: label, + pageLabel: label, + reportLabel: label, + viewLabel: (_object: string, _view: string, fallback?: string) => fallback ?? '', + }; + }, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Dev Admin', email: 'dev.admin@example.com' } }), + useWorkspaceAdminStatus: () => ({ isAdmin: state.isAdmin, isResolved: true }), +})); + +// One custom agent and no ask or build agent: AI is on here, so the build chip +// keeps its "Build an app" form (objectstack#26614 gives the agent-less +// deployment its own, `HomePage.noAgentsBuildChip-26614.test.tsx`), and no AI +// CTA joins the greeting row, which holds the builder chips alone. +vi.mock('@object-ui/plugin-chatbot/core', async (importOriginal) => ({ + ...(await importOriginal()), + useAgents: () => ({ agents: [{ name: 'custom_helper' }] }), + isAskAgent: () => false, + agentHasCapability: () => false, +})); + +vi.mock('../../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ apps: state.apps, objects: [], dashboards: [], pages: [], reports: [], loading: false }), +})); +vi.mock('../../../context/NavigationContext', () => ({ + useNavigationContext: () => ({ currentAppName: undefined }), +})); +vi.mock('../../../hooks/useCanAuthorMetadata', () => ({ + useCanAuthorMetadata: () => state.canAuthorMetadata, + useCanReadPendingDrafts: () => true, +})); +// The page's one inbox seam: the approvals count AND whether it is an answer. +vi.mock('../../../hooks/useHomeInbox', () => ({ + useHomeInbox: () => ({ + pendingApprovalsCount: state.pendingApprovalsCount, + // objectui#12602: the approvals preview, and whether each source is + // the caller's to read — the My-work section draws a column per source. + pendingApprovals: [], + pendingApprovalsReadable: true, + notificationsReadable: true, + pendingApprovalsStatus: state.approvalsStatus, + notifications: [], + unreadTopicCount: 0, + notificationsStatus: 'ready', + activity: { value: [], status: 'ready', readable: true }, + }), +})); +vi.mock('../../../runtime-config', () => ({ + getRuntimeConfig: () => ({ branding: { productName: 'ObjectStack' } }), + isMarketplaceEnabled: () => state.marketplaceEnabled, + isAiStudioEnabled: () => true, +})); + +// The tiles' last-opened time reads the recent-items store. +vi.mock('../../../hooks/useRecentItems', () => ({ + useRecentItems: () => ({ recentItems: state.recentItems }), +})); + +// --- surfaces these cases do not read --------------------------------------- +vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites: [] }) })); +vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' })); +vi.mock('../../../preview/usePendingDrafts', () => ({ usePendingDrafts: () => ({ count: 0 }) })); +vi.mock('../../../preview/usePublishAllDrafts', () => ({ + usePublishAllDrafts: () => ({ publishAll: async () => ({ ok: true }), publishing: false }), +})); + +import { HomePage } from '../HomePage'; +import { LauncherAppsProvider } from '../../../hooks/useLauncherApps'; + +const SETUP = { name: 'setup', label: 'Setup', icon: 'Settings', _packageId: 'com.objectstack.setup' }; +const CRM = { name: 'crm', label: 'CRM', icon: 'Building2' }; +const SHOWCASE = { name: 'showcase_app', label: 'Showcase', icon: 'Sparkles' }; + +const ENDPOINT = '/me-apps-double'; + +/** A `/me/apps` double: the listed names, or a bare status. */ +function meApps(answer: string[] | number) { + return vi.fn(async (_url: unknown) => + typeof answer === 'number' + ? new Response('{}', { status: answer }) + : new Response(JSON.stringify({ apps: answer.map((name) => ({ name })) }), { status: 200 }), + ); +} + +function renderHome(fetcher: unknown) { + return render( + + + , + ); +} + +/** Every URL the page handed the global `fetch` — none is expected. */ +let fetchCalls: string[] = []; + +beforeEach(() => { + navigateMock.mockReset(); + state.isAdmin = true; + state.canAuthorMetadata = true; + state.marketplaceEnabled = true; + state.pendingApprovalsCount = 0; + state.approvalsStatus = 'ready'; + state.apps = [SETUP, CRM, SHOWCASE]; + state.recentItems = []; + fetchCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + fetchCalls.push(String(input)); + return { ok: false, status: 404, json: async () => ({}) }; + }), + ); +}); + +afterEach(() => { + expect(fetchCalls).toEqual([]); + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('Home draws a tile only for an app /me/apps lists (objectstack#26838)', () => { + it('an app the member’s set hides draws no tile', async () => { + const fetcher = meApps(['setup', 'crm']); + renderHome(fetcher); + expect(await screen.findByTestId('app-tile-crm')).toBeInTheDocument(); + expect(screen.queryByTestId('app-tile-showcase_app')).toBeNull(); + expect(screen.getByTestId('home-system-settings-link')).toBeInTheDocument(); + expect(screen.getByTestId('home-apps-count')).toHaveTextContent(/^1$/); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('a set that hides Setup takes the System Settings link away', async () => { + renderHome(meApps(['crm', 'showcase_app'])); + expect(await screen.findByTestId('app-tile-showcase_app')).toBeInTheDocument(); + expect(screen.getByTestId('app-tile-crm')).toBeInTheDocument(); + expect(screen.queryByTestId('home-system-settings-link')).toBeNull(); + }); + + it('CONTROL — a 404 (no security plugin) draws every tile, as before', async () => { + renderHome(meApps(404)); + expect(await screen.findByTestId('app-tile-showcase_app')).toBeInTheDocument(); + expect(screen.getByTestId('app-tile-crm')).toBeInTheDocument(); + expect(screen.getByTestId('home-system-settings-link')).toBeInTheDocument(); + }); + + it('holds the loading line, and no tile, while the first read is out', async () => { + let answer!: (res: Response) => void; + renderHome(vi.fn(() => new Promise((resolve) => (answer = resolve)))); + expect(await screen.findByText('«home.loading»')).toBeInTheDocument(); + expect(screen.queryByTestId('app-tile-showcase_app')).toBeNull(); + expect(screen.queryByTestId('app-tile-crm')).toBeNull(); + + await act(async () => { + answer(new Response(JSON.stringify({ apps: [{ name: 'setup' }, { name: 'crm' }] }), { status: 200 })); + }); + expect(screen.getByTestId('app-tile-crm')).toBeInTheDocument(); + expect(screen.queryByTestId('app-tile-showcase_app')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/hooks/__tests__/useLauncherApps-26838.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useLauncherApps-26838.test.tsx new file mode 100644 index 00000000000..eb29971aea4 --- /dev/null +++ b/ui/packages/app-shell/src/hooks/__tests__/useLauncherApps-26838.test.tsx @@ -0,0 +1,186 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26838 — `useLauncherApps` narrows a launcher's `/meta/app` list + * by the user's `GET /me/apps` answer, which drops an app a permission set + * hides (`tabPermissions: hidden`). + * + * What these cases pin, on the real `LauncherAppsProvider` fed a `/me/apps` + * double: + * + * - an app the answer did not list is not offered; the rest keep the + * `/meta/app` entries themselves; + * - no provider, a `404` and a failed read all narrow nothing — the last + * says so once, the `404` (no security plugin) does not; + * - `pending` holds only while the FIRST read is out; + * - an app the answer was never asked about passes, and its arrival asks + * again; two consumers share one read. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; + +const state = vi.hoisted(() => ({ + userId: 'u1' as string | null, + apps: [] as Array>, + loading: false, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: state.userId ? { id: state.userId } : null }), +})); +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ apps: state.apps, loading: state.loading }), +})); + +import { LauncherAppsProvider, useLauncherApps } from '../useLauncherApps'; + +const SETUP = { name: 'setup', label: 'Setup' }; +const ACCOUNT = { name: 'account', label: 'Account', hidden: true }; +const CRM = { name: 'crm', label: 'CRM' }; +const SHOWCASE = { name: 'showcase_app', label: 'Showcase' }; + +const ENDPOINT = '/me-apps-double'; + +/** A `/me/apps` double answering `names`, or `status` with no body. */ +function meApps(answer: string[] | number) { + return vi.fn(async (_url: unknown) => + typeof answer === 'number' + ? new Response('{}', { status: answer }) + : new Response(JSON.stringify({ apps: answer.map((name) => ({ name })) }), { status: 200 }), + ); +} + +/** Prints what the hook answers: the offered names, and `pending`. */ +function Probe({ id = 'probe' }: { id?: string }) { + const { apps, pending } = useLauncherApps(state.apps); + return ( +
+ {apps.map((app) => String(app.name)).join(',')} +
+ ); +} + +function renderWith(fetcher: typeof fetch, children: React.ReactNode = ) { + return render( + + {children} + , + ); +} + +beforeEach(() => { + state.userId = 'u1'; + state.apps = [SETUP, ACCOUNT, CRM, SHOWCASE]; + state.loading = false; +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('useLauncherApps narrows by /me/apps (objectstack#26838)', () => { + it('drops an app the /me/apps answer does not list, keeping the /meta/app entries', async () => { + const fetcher = meApps(['setup', 'account', 'crm']); + const seen = vi.fn<(apps: unknown[]) => void>(); + function Entries() { + const { apps } = useLauncherApps(state.apps); + React.useEffect(() => seen(apps), [apps]); + return null; + } + renderWith(fetcher as unknown as typeof fetch, <>); + await waitFor(() => expect(screen.getByTestId('probe')).toHaveTextContent(/^setup,crm$/)); + expect(screen.getByTestId('probe')).toHaveAttribute('data-pending', 'false'); + // The entries are `/meta/app`'s own objects, not the answer's. + const entries = seen.mock.lastCall?.[0]; + expect(entries).toEqual([SETUP, CRM]); + expect(entries?.[0]).toBe(SETUP); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(fetcher.mock.calls[0][0]).toBe(ENDPOINT); + }); + + it('outside a provider narrows nothing and is not pending', () => { + render(); + expect(screen.getByTestId('probe')).toHaveTextContent(/^setup,crm,showcase_app$/); + expect(screen.getByTestId('probe')).toHaveAttribute('data-pending', 'false'); + }); + + it('a 404 (no security plugin) narrows nothing, silently, and is not asked again', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fetcher = meApps(404); + const { rerender } = renderWith(fetcher as unknown as typeof fetch); + await waitFor(() => expect(screen.getByTestId('probe')).toHaveAttribute('data-pending', 'false')); + expect(screen.getByTestId('probe')).toHaveTextContent(/^setup,crm,showcase_app$/); + expect(warn).not.toHaveBeenCalled(); + + state.apps = [...state.apps, { name: 'ops', label: 'Ops' }]; + rerender( + + + , + ); + expect(screen.getByTestId('probe')).toHaveTextContent(/^setup,crm,showcase_app,ops$/); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('a failed read narrows nothing and says so once', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + renderWith(meApps(500) as unknown as typeof fetch); + await waitFor(() => expect(screen.getByTestId('probe')).toHaveAttribute('data-pending', 'false')); + expect(screen.getByTestId('probe')).toHaveTextContent(/^setup,crm,showcase_app$/); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('/me/apps'); + }); + + it('is pending only while the first read is out', async () => { + let answer!: (res: Response) => void; + const fetcher = vi.fn(() => new Promise((resolve) => (answer = resolve))); + renderWith(fetcher as unknown as typeof fetch); + await waitFor(() => expect(screen.getByTestId('probe')).toHaveAttribute('data-pending', 'true')); + await act(async () => { + answer(new Response(JSON.stringify({ apps: [{ name: 'crm' }] }), { status: 200 })); + }); + expect(screen.getByTestId('probe')).toHaveAttribute('data-pending', 'false'); + expect(screen.getByTestId('probe')).toHaveTextContent(/^crm$/); + }); + + it('an app the answer was not asked about passes, and its arrival asks again', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ apps: [{ name: 'crm' }] }), { status: 200 })) + .mockReturnValueOnce(new Promise(() => {})); + const { rerender } = renderWith(fetcher as unknown as typeof fetch); + await waitFor(() => expect(screen.getByTestId('probe')).toHaveTextContent(/^crm$/)); + + // Installed after the read: offered while the re-read is out, not pending. + state.apps = [...state.apps, { name: 'ops', label: 'Ops' }]; + rerender( + + + , + ); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2)); + expect(screen.getByTestId('probe')).toHaveTextContent(/^crm,ops$/); + expect(screen.getByTestId('probe')).toHaveAttribute('data-pending', 'false'); + }); + + it('two consumers share one read', async () => { + const fetcher = meApps(['crm']); + renderWith(fetcher as unknown as typeof fetch, <>); + await waitFor(() => expect(screen.getByTestId('a')).toHaveTextContent(/^crm$/)); + expect(screen.getByTestId('b')).toHaveTextContent(/^crm$/); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('asks nothing without a signed-in user', () => { + state.userId = null; + const fetcher = meApps(['crm']); + renderWith(fetcher as unknown as typeof fetch); + expect(screen.getByTestId('probe')).toHaveTextContent(/^setup,crm,showcase_app$/); + expect(fetcher).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/app-shell/src/hooks/index.ts b/ui/packages/app-shell/src/hooks/index.ts index b74b6b7e178..9dd026b4fc1 100644 --- a/ui/packages/app-shell/src/hooks/index.ts +++ b/ui/packages/app-shell/src/hooks/index.ts @@ -63,6 +63,13 @@ export { } from './useUrlOverlay.js'; export { useTenancyPosture } from './useTenancyPosture.js'; export { useHomePath } from './useHomePath.js'; +export { + LauncherAppsProvider, + useLauncherApps, + useServedApps, + type LauncherApps, + type LauncherAppsProviderProps, +} from './useLauncherApps.js'; export { useTrackRouteAsRecent, type UseTrackRouteAsRecentOptions } from './useTrackRouteAsRecent.js'; export { sanitizeChatMessagesForCache, diff --git a/ui/packages/app-shell/src/hooks/useLauncherApps.tsx b/ui/packages/app-shell/src/hooks/useLauncherApps.tsx new file mode 100644 index 00000000000..5f6988898a8 --- /dev/null +++ b/ui/packages/app-shell/src/hooks/useLauncherApps.tsx @@ -0,0 +1,226 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `useLauncherApps` — the apps a launcher surface offers, as the SERVER filters + * them for this user (objectstack#26838). + * + * `GET /api/v1/meta/app` serves every app the caller may open, routing + * included: a permission set's `tabPermissions: { : 'hidden' }` does not + * withhold an app there, and must not — a hidden app keeps opening from a + * direct URL (UI visibility, not access control). The hide is applied in one + * place, `GET /api/v1/me/apps`, which merges the caller's sets (most visible + * wins) and drops the hidden ones. Home read the first list and drew a tile + * for an app the member's set hides; the app switcher and the `app:launcher` + * page block read the same list and offered it too. + * + * So every launcher surface narrows its `/meta/app` list through this hook, + * and the hook through ONE read of `/me/apps` per user, held by + * {@link LauncherAppsProvider}. Three surfaces, one answer: they cannot + * disagree about which app is offered. + * + * What the answer narrows, and what it leaves: + * + * - The list keeps `/meta/app`'s entries — their labels, icons, package ids + * and publish state are that route's. `/me/apps` contributes NAMES only. + * - An app the answer was never asked about is not hidden by it. The provider + * records the active app names it asked with; an app that arrives later (an + * install, a publish) passes until the re-read it triggers lands, rather + * than vanishing from the launcher it was just installed into. + * - No provider mounted, a `404` (no security plugin serves the route), or a + * failed read: nothing narrows, and the surface shows the `/meta/app` list + * as it always did. That is the server's own posture — `/me/apps` fails open + * without the security plugin — and the same "cosmetic, never fail-closed" + * one the console's other personal reads take. A failure other than `404` + * says so once on the console. + * - `pending` is true only while the FIRST read is out. Home waits on it, so a + * hidden tile is never drawn and then taken away; a re-read keeps serving + * the answer it replaces. + * + * `default_off` is not read here: it is not a hide. + * + * @module + */ + +import * as React from 'react'; +import { createContext, useContext, useEffect, useRef, useState } from 'react'; +import { createAuthenticatedFetch, useAuth } from '@object-ui/auth'; +import { useMetadata } from '../providers/MetadataProvider.js'; +import { filterActiveApps } from '../utils/appRoute.js'; +import { resolveRuntimeApiBase } from './storageUsageEndpoint.js'; + +type LauncherAppLike = { name?: unknown } & Record; + +/** One `/me/apps` answer, and the app names it speaks for. */ +interface LauncherAnswer { + /** The app names `/me/apps` listed. */ + served: ReadonlySet; + /** The active `/meta/app` names when the read went out. */ + asked: ReadonlySet; +} + +type LauncherAppsState = + | { status: 'idle' } + | { status: 'pending' } + | { status: 'ready'; answer: LauncherAnswer } + | { status: 'unavailable' }; + +const LauncherAppsContext = createContext(null); + +/** The provider's state, and the user it belongs to. */ +interface HeldAnswer { + owner: string | null; + state: LauncherAppsState; +} + +const NOTHING_HELD: HeldAnswer = { owner: null, state: { status: 'idle' } }; + +/** A non-2xx answer, kept apart so `404` (route not served) reads as absence. */ +class LauncherAppsReadError extends Error { + constructor(readonly httpStatus: number) { + super(`GET /me/apps returned ${httpStatus}`); + this.name = 'LauncherAppsReadError'; + } +} + +async function readServedAppNames(url: string, doFetch: typeof fetch): Promise> { + const res = await doFetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + credentials: 'include', + }); + if (!res.ok) throw new LauncherAppsReadError(res.status); + const body = (await res.json()) as { apps?: unknown } | null; + if (!Array.isArray(body?.apps)) throw new Error('GET /me/apps answered without an `apps` array'); + const names = new Set(); + for (const app of body.apps as Array<{ name?: unknown } | null>) { + if (typeof app?.name === 'string') names.add(app.name); + } + return names; +} + +function activeAppNames(apps: readonly LauncherAppLike[] | null | undefined): string[] { + const names = new Set(); + for (const app of filterActiveApps(apps)) { + if (typeof app?.name === 'string') names.add(app.name); + } + return [...names].sort(); +} + +export interface LauncherAppsProviderProps { + children?: React.ReactNode; + /** Defaults to `{VITE_SERVER_URL}/api/v1/me/apps`. */ + endpoint?: string; + /** + * Defaults to a fresh `createAuthenticatedFetch()` per read: the console's + * `/api/v1/*` lane carries a Bearer token and no session cookie. + */ + fetcher?: typeof fetch; +} + +/** + * Holds the signed-in user's `/me/apps` answer for every launcher surface + * below it. Mount it once, where Home and the apps share an ancestor — the + * console mounts it beside the `/auth/me/permissions` provider. + * + * Reads when there is a user and an app list to narrow, and again when the + * app list gains a name the held answer was not asked about. + */ +export function LauncherAppsProvider({ children, endpoint, fetcher }: LauncherAppsProviderProps) { + const { user } = useAuth(); + const { apps, loading } = useMetadata(); + const owner = user?.id != null ? String(user.id) : null; + // The effect keys on the names' VALUE, never on the list's identity. + const namesKey = JSON.stringify(activeAppNames(apps as LauncherAppLike[] | undefined)); + + const [held, setHeld] = useState(NOTHING_HELD); + // The effect's reading of the current answer, without keying on it. + const heldRef = useRef(NOTHING_HELD); + // Owners whose server answered `404`: the route is not served, so the same + // question is not asked again for them. + const notServed = useRef(new Set()); + + useEffect(() => { + const names = JSON.parse(namesKey) as string[]; + const publish = (next: HeldAnswer) => { + heldRef.current = next; + setHeld(next); + }; + if (!owner || loading || names.length === 0 || notServed.current.has(owner)) return; + const current = heldRef.current.owner === owner ? heldRef.current.state : null; + const answer = current?.status === 'ready' ? current.answer : null; + if (answer && names.every((name) => answer.asked.has(name))) return; + + let cancelled = false; + const asked = new Set(names); + if (!current || current.status === 'idle') publish({ owner, state: { status: 'pending' } }); + const url = endpoint ?? `${resolveRuntimeApiBase()}/me/apps`; + readServedAppNames(url, fetcher ?? createAuthenticatedFetch()) + .then((served) => { + if (cancelled) return; + publish({ owner, state: { status: 'ready', answer: { served, asked } } }); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (err instanceof LauncherAppsReadError && err.httpStatus === 404) { + notServed.current.add(owner); + } else { + console.warn( + '[app-shell] Could not read the apps your permission sets show (`GET /me/apps`); ' + + 'the launcher lists every app you can open instead:', + err, + ); + } + // A failed re-read keeps the answer it would have replaced. + publish({ owner, state: answer ? { status: 'ready', answer } : { status: 'unavailable' } }); + }); + return () => { + cancelled = true; + }; + }, [owner, loading, namesKey, endpoint, fetcher]); + + const state: LauncherAppsState = held.owner === owner ? held.state : { status: 'idle' }; + return {children}; +} + +export interface LauncherApps { + /** The apps to offer: `filterActiveApps(apps)`, narrowed by `/me/apps`. */ + apps: T[]; + /** The first `/me/apps` read is still out; nothing is narrowed yet. */ + pending: boolean; +} + +/** + * The apps a launcher surface offers: `filterActiveApps(apps)`, minus every + * app the user's `/me/apps` answer was asked about and did not list. Outside + * a {@link LauncherAppsProvider}, or when the read is absent or failed, that + * is `filterActiveApps(apps)` unchanged. + */ +export function useLauncherApps( + apps: readonly T[] | null | undefined, +): LauncherApps { + const state = useContext(LauncherAppsContext); + return { + apps: narrowByAnswer(state, filterActiveApps(apps)), + pending: state?.status === 'pending', + }; +} + +/** + * `apps` minus every app the user's `/me/apps` answer was asked about and did + * not list, with no other filter: for a surface that keeps its own rule for + * which apps it lists (the command palette lists `hidden` apps too) but offers + * none a permission set hides. Unchanged outside a provider or without an + * answer, exactly as {@link useLauncherApps}. + */ +export function useServedApps(apps: readonly T[]): T[] { + return narrowByAnswer(useContext(LauncherAppsContext), apps); +} + +function narrowByAnswer(state: LauncherAppsState | null, apps: readonly T[]): T[] { + if (state?.status !== 'ready') return [...apps]; + const { served, asked } = state.answer; + return apps.filter((app) => { + const name = app?.name; + return typeof name !== 'string' || served.has(name) || !asked.has(name); + }); +} diff --git a/ui/packages/app-shell/src/index.ts b/ui/packages/app-shell/src/index.ts index 44f9fbaf060..124b25bd7e1 100644 --- a/ui/packages/app-shell/src/index.ts +++ b/ui/packages/app-shell/src/index.ts @@ -237,8 +237,11 @@ export { useResponsiveSidebar, useActionModal, useHomePath, + LauncherAppsProvider, + useLauncherApps, + useServedApps, } from './hooks/index.js'; -export type { FavoriteItem } from './hooks/index.js'; +export type { FavoriteItem, LauncherApps, LauncherAppsProviderProps } from './hooks/index.js'; // Context providers export { diff --git a/ui/packages/app-shell/src/layout/AppSwitcher.tsx b/ui/packages/app-shell/src/layout/AppSwitcher.tsx index b58b5bd0b47..d4b6058ebce 100644 --- a/ui/packages/app-shell/src/layout/AppSwitcher.tsx +++ b/ui/packages/app-shell/src/layout/AppSwitcher.tsx @@ -37,6 +37,7 @@ import { useMetadata } from '../providers/MetadataProvider.js'; import { resolveKeyedI18nLabel, matchAppBySegment, appRouteSegment } from '../utils/index.js'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { useHomePath } from '../hooks/useHomePath.js'; +import { useLauncherApps } from '../hooks/useLauncherApps.js'; import { SETUP_APP_NAME, SETUP_APP_PACKAGE_ID } from '../console/ConsoleShell.js'; import { GO_HOME_SHORTCUT, shortcutKeycaps } from '../chrome/advertisedShortcuts.js'; import { isSingleAppSwitcherHidden } from '../runtime-config.js'; @@ -83,7 +84,12 @@ export function AppSwitcher({ activeAppName, onAppChange, designInStudioPath }: // this list is already permitted. Filtering it here would hide a builder's own // in-progress app from the builder. Pinned by // `__tests__/AppSwitcher.publishState.test.tsx`. - const activeApps = apps.filter((a: any) => a.active !== false && a.hidden !== true); + // + // An app the user's permission sets hide (`tabPermissions: hidden`) is not + // offered either: the menu offers what Home's tiles offer, narrowed by the + // `/me/apps` answer (objectstack#26838). The pill below still names such an app when the user + // is in it — it opens from a direct URL, so it is where they are. + const { apps: activeApps } = useLauncherApps(apps); // ADR-0048 (A) — route segment may be a package id; match by it (name fallback). const activeApp = matchAppBySegment(activeApps, activeAppName) || matchAppBySegment(apps.filter((a: any) => a.active !== false), activeAppName) || activeApps[0]; diff --git a/ui/packages/app-shell/src/layout/__tests__/AppSwitcher.launcherHiddenApps-26838.test.tsx b/ui/packages/app-shell/src/layout/__tests__/AppSwitcher.launcherHiddenApps-26838.test.tsx new file mode 100644 index 00000000000..28e338de772 --- /dev/null +++ b/ui/packages/app-shell/src/layout/__tests__/AppSwitcher.launcherHiddenApps-26838.test.tsx @@ -0,0 +1,132 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26838 — the App Switcher's menu offers what Home's tiles offer: + * the active `/meta/app` list narrowed by the user's `GET /me/apps` answer, so + * an app a permission set hides (`tabPermissions: hidden`) is not in it. The + * pill still names that app while the user is in it — it opens from a URL. + * + * The menu harness is `AppSwitcher.publishState.test.tsx`'s. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +// Passthrough dropdown primitives so the menu CONTENT renders without an +// interaction — the same idiom as WorkspaceSwitcher.test.tsx. What is under +// measurement is which apps reach the list, not radix's open/close machinery. +vi.mock('@object-ui/components', async (importOriginal) => ({ + ...(await importOriginal>()), + DropdownMenu: (p: any) =>
{p.children}
, + DropdownMenuTrigger: (p: any) =>
{p.children}
, + // Scoped: the trigger renders the ACTIVE app's label too, so an unscoped + // `getByText` would match twice. Every assertion below reads the LIST. + DropdownMenuContent: (p: any) =>
{p.children}
, + DropdownMenuItem: ({ onClick, children }: any) =>
{children}
, + DropdownMenuLabel: (p: any) =>
, + DropdownMenuSeparator: () =>
, +})); +vi.mock('lucide-react', async (importOriginal) => ({ + ...(await importOriginal>()), + ChevronDown: () => , + Check: () => , +})); +// `getIcon` wraps lucide's DynamicIcon (a per-icon dynamic import); stub it so +// no unbounded module load races the assertions. +vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => })); + +// Spread the real module rather than replacing it: other modules in this graph +// import further exports from it, and a bare factory would blank them out. +vi.mock('@object-ui/i18n', async () => { + const actual = await vi.importActual>('@object-ui/i18n'); + return { + ...actual, + useObjectTranslation: () => ({ + t: (key: string, o?: { defaultValue?: string }) => o?.defaultValue ?? key, + }), + useObjectLabel: () => ({ + appLabel: ({ name, label }: { name: string; label?: string }) => label ?? name, + }), + }; +}); + +// Keep the real route-segment matching (`appRouteSegment` / `matchAppBySegment`); +// only the keyed-i18n resolver is stubbed. +vi.mock('../../utils', async () => { + const appRoute = await vi.importActual('../../utils/appRoute'); + return { + ...appRoute, + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : undefined), + }; +}); + +let apps: Array> = []; +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ apps, loading: false }), +})); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1' } }), +})); + +import { AppSwitcher } from '../AppSwitcher'; +import { LauncherAppsProvider } from '../../hooks/useLauncherApps'; + +/** A `/me/apps` double: the listed names, or a bare status. */ +function meApps(answer: string[] | number) { + return vi.fn(async (_url: unknown) => + typeof answer === 'number' + ? new Response('{}', { status: answer }) + : new Response(JSON.stringify({ apps: answer.map((name) => ({ name })) }), { status: 200 }), + ); +} + +async function renderSwitcher(fetcher: unknown, active = 'crm') { + render( + + + + + , + ); + // Let the double's answer land and be drawn. + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + return within(screen.getByTestId('app-switcher-menu')); +} + +beforeEach(() => { + apps = [ + { name: 'crm', label: 'CRM' }, + { name: 'showcase_app', label: 'Showcase' }, + { name: 'ops', label: 'Ops' }, + ]; +}); + +afterEach(() => { + cleanup(); +}); + +describe('AppSwitcher offers only the apps /me/apps lists (objectstack#26838)', () => { + it('an app the member’s set hides is not in the menu', async () => { + const menu = await renderSwitcher(meApps(['crm', 'ops'])); + expect(menu.getByText('CRM')).toBeInTheDocument(); + expect(menu.getByText('Ops')).toBeInTheDocument(); + expect(menu.queryByText('Showcase')).toBeNull(); + }); + + it('CONTROL — a 404 (no security plugin) offers every app, as before', async () => { + const menu = await renderSwitcher(meApps(404)); + expect(menu.getByText('Showcase')).toBeInTheDocument(); + }); + + it('the pill still names a hidden app while the user is in it', async () => { + const menu = await renderSwitcher(meApps(['crm', 'ops']), 'showcase_app'); + expect(screen.getByTestId('app-switcher-trigger')).toHaveAttribute('title', 'Showcase'); + expect(menu.queryByText('Showcase')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/app-launcher-renderer.tsx b/ui/packages/app-shell/src/views/app-launcher-renderer.tsx index 7b552ed7606..2e90735f84d 100644 --- a/ui/packages/app-shell/src/views/app-launcher-renderer.tsx +++ b/ui/packages/app-shell/src/views/app-launcher-renderer.tsx @@ -40,9 +40,11 @@ * eagerly (`app` is in its `EAGER_TYPES`, i.e. `GET /api/v1/meta/app` on * mount). That is the SAME read the top-bar `AppSwitcher` and the Home page * make, and the openable-apps filter is the shared `filterActiveApps` helper - * rather than a second copy of the `active`/`hidden` rule. This is the ruling's - * "no external data-source dependency" claim, discharged: the block issues no - * request of its own and reaches no adapter. + * rather than a second copy of the `active`/`hidden` rule, narrowed by + * `useLauncherApps` — the user's `/me/apps` answer, which the console's one + * `LauncherAppsProvider` reads for Home, the switcher and this block alike. + * This is the ruling's "no external data-source dependency" claim, discharged: + * the block issues no request of its own and reaches no adapter. * * The grid itself is `HomeAppsStrip` — the console's existing launcher, already * the answer to "how does this product draw a wall of apps" — so an authored @@ -82,13 +84,12 @@ */ import * as React from 'react'; -import { useMemo } from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { useMetadata } from '@object-ui/react'; import { useObjectTranslation } from '@object-ui/i18n'; import { HomeAppsStrip } from '../console/home/HomeAppsStrip.js'; import { useFavorites } from '../hooks/useFavorites.js'; -import { filterActiveApps } from '../utils/index.js'; +import { useLauncherApps } from '../hooks/useLauncherApps.js'; /** Keep the designer's own data attributes on the wrapper, drop the rest. */ const splitDesigner = (props: Record) => { @@ -113,8 +114,9 @@ export const AppLauncherRenderer: React.FC = ({ // The openable set, through the SHARED predicate — `active !== false && // hidden !== true`. Two copies of "an app the user can open" is precisely - // the dialect `filterActiveApps` exists to prevent. - const openable = useMemo(() => filterActiveApps(apps as any[]), [apps]); + // the dialect `filterActiveApps` exists to prevent. Narrowed, as Home's own + // tiles are, by the user's `/me/apps` answer (objectstack#26838). + const { apps: openable } = useLauncherApps(apps as any[]); return (