Skip to content

Commit 85c40fd

Browse files
fix(app-shell): Home, the app switcher and launcher blocks offer only the apps /me/apps lists, so a tabPermissions-hidden app draws no tile (#27021)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8X61MrFcMwootjvrLF4Py6o)_ Fixes #26838 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:首页磁贴、应用切换器菜单和 `app:launcher` 页面块都按当前用户的 `GET /me/apps` 结果收窄应用列表,权限集里 `tabPermissions: hidden` 的应用不再出现。命令面板的应用列表和不带应用段的默认落地也按 /me/apps 收窄。 - 为什么改:隐藏只在 `/me/apps` 生效,而这三个入口都读 `/meta/app`(卡上说切换器已正确,实测它也读 `/meta/app`),成员仍能看到并点开被管理员隐藏的应用。 - 风险与回滚:无安全插件时 `/me/apps` 答 404、或读取失败,一律回退到原来的完整列表;首页多等一次 `/me/apps` 首读。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/hooks/useLauncherApps.tsx`(收窄与回退规则)、`ui/apps/console/src/AppContent.tsx`(唯一挂载点)、`ui/packages/app-shell/src/console/home/HomePage.tsx`(首页等待首读)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/console/home/__tests__/HomePage.launcherHiddenApps-26838.test.tsx`,预期 4 个用例通过,被隐藏的 Showcase 不出磁贴。 - 需要你做的:无,席位落地。 ## What changes - New `ui/packages/app-shell/src/hooks/useLauncherApps.tsx`: - `LauncherAppsProvider` reads `GET /api/v1/me/apps` once per signed-in user, and again only when the active `/meta/app` name set gains a name the held answer was not asked about (an install or a publish mid-session). - `useLauncherApps(apps)` returns `filterActiveApps(apps)` minus every app the answer was asked about and did not list. The entries stay `/meta/app`'s own objects (labels, icons, `_packageId`, publish state); `/me/apps` contributes names only. - No provider, a `404` (no security plugin serves the route) or a failed read narrows nothing. A failure other than `404` logs one `console.warn`; a `404` is not asked again for that user. - `pending` is true only while the first read is out. - `ui/apps/console/src/AppContent.tsx`: `ConsolePermissionsScope`, the one common ancestor of `/home` and `/apps/:appName/*`, mounts `LauncherAppsProvider` inside `MePermissionsProvider`, with the same server URL and authenticated fetch. The seat extended the claim's file surface to this file (amendment 6457698059). - `HomePage`: the tiles and the System Settings link come from `useLauncherApps`; the page's loading gate also waits for the first read, so a hidden tile is never drawn and then removed. The host-app resolution and the recent rows' app labels keep the full active list, because a hidden app still opens. - `AppSwitcher`: the menu lists `useLauncherApps(apps)`. The pill still names a hidden app while the user is in it (its existing fallback over all active apps). - `app:launcher` block (`views/app-launcher-renderer.tsx`): its openable set is `useLauncherApps(apps)`; it still issues no request of its own. - `LauncherAppsProvider` and `useLauncherApps` are exported from `@object-ui/app-shell`. - `useServedApps(apps)` (exported): the `/me/apps` narrowing alone, no active/hidden filter. `CommandPalette` lists `useServedApps(apps.filter(active !== false))`, so its own `hidden: true` behaviour is unchanged. - app-shell `console/AppContent.tsx`: the default-app landing (a URL naming no or an unknown app) picks from `useLauncherApps(apps)`; the route lookup still finds a hidden app, which keeps opening from its URL. - Changeset `.changeset/26838-launcher-hidden-tabs.md`: `@object-ui/app-shell` and `@object-ui/console` patch. Not changed: no server change, no route guard (a hidden app keeps opening from its URL), `default_off` untouched, `console/appObjectScope.ts` untouched. ### Why a provider, not a hook that fetches No `@objectstack/client` method reads `/me/apps`, so the read is a direct authenticated fetch. The ui network-escape guard (`ui/vitest.setup.network-escape-guard.ts`) reds any test whose render reaches a relative fetch, and about 130 test files render `HomePage`, `AppSwitcher`, `AppHeader` or `HomeLayout`. An explicitly mounted provider, the `MePermissionsProvider` pattern, keeps those tests unchanged: without it a surface reads the unnarrowed list, exactly as before. ## Tests - `hooks/__tests__/useLauncherApps-26838.test.tsx` (8 cases): narrows by the answer and keeps the `/meta/app` entries; no provider narrows nothing; `404` narrows nothing, silently, and is not re-asked; `500` narrows nothing and warns once; `pending` only for the first read; an app added after the read passes and triggers one re-read; two consumers share one read; no user, no read. - `console/home/__tests__/HomePage.launcherHiddenApps-26838.test.tsx` (4 cases): `/meta/app` = [setup, crm, showcase_app] and `/me/apps` = [setup, crm] draws no Showcase tile; a set hiding Setup removes the System Settings link; the `404` control draws every tile; the loading line holds while the first read is out. - `layout/__tests__/AppSwitcher.launcherHiddenApps-26838.test.tsx` (3 cases): the hidden app is not in the menu; the `404` control lists it; the pill still names it when the user is in it. - `chrome/CommandPalette.launcherHiddenApps-26838.test.tsx` (2 cases): the hidden app is not offered; the `404` control offers it. - `console/__tests__/AppContent.defaultLandingHiddenApp-26838.test.tsx` (3 cases): the declared default `crm` hidden by `/me/apps` = [sales] makes an unknown-app URL land on `sales`; the `404` control keeps `crm`; a URL naming `crm` still opens it. <details><summary>Ablations (one-time; restored, blob equals HEAD each time)</summary> Each leg ran under the lock through `node scripts/ablation-replace.mjs` (anchor hit 1 -> 0, restored blob equals HEAD, `git diff HEAD` empty), on commit `9f4f6f31`: - `HomePage.tsx`: the tiles from `activeApps` instead of `launcher.apps` -> `HomePage.launcherHiddenApps-26838`: 2 failed / 2 passed (the hidden-tile case and the loading-hold case go red). - `useLauncherApps.tsx`: the narrowing predicate replaced by `return true` -> hook, Home and switcher files: 7 cases red across the three files. - `AppSwitcher.tsx`: the menu from the old inline `active`/`hidden` filter -> `AppSwitcher.launcherHiddenApps-26838`: 2 failed / 1 passed. - Round 2, on `bb175bf7`: `CommandPalette` `useServedApps(...)` -> the plain active filter: 1 failed / 1 passed; app-shell `console/AppContent` `useLauncherApps(apps)` -> the inline active/hidden filter: 1 failed / 2 passed. </details> ## Acceptance notes - **CommandPalette** and **default-app landing**: fixed in this PR at the seat's REWORK (6458597628). - **Card premise correction**: the card says the app switcher reads `/me/apps` and hides the app. On `origin/main` no file under `ui/` read `/me/apps`; the switcher read `/meta/app` like Home. Both are fixed here. - **Observation, not filed**: `GET /me/apps` answers `200 { apps: [] }` when its handler throws (the outer `catch` in `current-user-endpoints.ts`). This client cannot tell that from "every app is hidden", so on that answer Home draws no app tiles and no System Settings link (only the page frame), the switcher menu lists no app, and the palette offers no app; a hidden-by-throw state lasts until the next read (a new user or a new app name). Read-only inference from source; not reproduced. - Home now waits for the first `/me/apps` read alongside `/meta/app`; the read starts once the app list has loaded, so it adds one request round trip before the tiles draw on a cold load. ## Verification Round 2 on head `7b20f2d5` (`8cc708ab` merged `origin/main` `acc9c57d`; `7b20f2d5` changes one test file): affected set 308 files / 2514 tests passed on `8cc708ab`; `dispatch-gates --commands` 34/34 exit 0 and `--ran` 0 NOT-MEASURED on `8cc708ab`; app-shell package lint 0 errors; CI on `7b20f2d5` green (43 success, 10 skipped). Round 1 readings below. Round 1 on head `cbe8d370` (origin/main merged in), app-shell closure built first (7m33s under the lock). - New tests: 3 files, 15/15 passed. - Affected set: every app-shell and console test file that names `HomePage`, `AppSwitcher`, `AppHeader`, `HomeLayout`, `ConsoleLayout`, `AppContent`, the launcher block or the hook, plus all of `console/home/__tests__` and `layout/__tests__`: 284 files, 2359 tests passed (on `9f4f6f31`, before the merge of origin/main, which touched no file under these paths). - `pnpm --filter @object-ui/app-shell run type-check`: exit 0. `pnpm --filter @object-ui/console run type-check`: exit 0 (after `--filter '@object-ui/console^...' build`). - `node scripts/pm/dispatch-gates.mjs --commands`: 34 derived commands, each exit 0; `--ran` reconciliation: 34 derived, 34 run, 0 NOT-MEASURED. Includes `node scripts/check-sdui-manifest.mjs` (exit 0) and `pnpm --dir ui exec vitest run scripts/` (exit 0). - Also run: `pnpm --dir ui run check:spec-symbols`, `check:new-line-citations`, `check:test-path-roots`: exit 0. - CI owns the repo lint and the remaining type-check lanes. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI runs the repo lint) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbYwBbR8jCpWPE5Kosz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6af79c1 commit 85c40fd

15 files changed

Lines changed: 1169 additions & 18 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(app-shell): Home, the app switcher and the `app:launcher` block no longer offer an app the user's permission sets hide
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
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.
12+
13+
What changes:
14+
15+
- 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.
16+
- 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.
17+
- Home waits for the first answer, as it waits for the app list, so a hidden tile is never drawn and then removed.
18+
- 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.
19+
- 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.
20+
21+
What to do: nothing. A permission set that already hides an app now hides it on Home, in the switcher and in launcher blocks.

‎ui/apps/console/src/AppContent.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import { lazy, Suspense, useMemo, type ReactNode } from 'react';
1313
import { Route, useParams, useLocation, Navigate } from 'react-router-dom';
14-
import { DefaultAppContent, ENVIRONMENT_SCOPE_QUERY, LoadingScreen, useAdapter } from '@object-ui/app-shell';
14+
import { DefaultAppContent, ENVIRONMENT_SCOPE_QUERY, LauncherAppsProvider, LoadingScreen, useAdapter } from '@object-ui/app-shell';
1515
import { AdapterCtx } from '@object-ui/react';
1616
import { MePermissionsProvider, ServerReadScope } from '@object-ui/permissions';
1717
import { createAuthenticatedFetch } from '@object-ui/auth';
@@ -406,7 +406,13 @@ export function ConsolePermissionsScope({ children }: { children: ReactNode }) {
406406
// error + retry affordance all along.
407407
errorFallback={(err, retry) => <LoadingScreen error={err.message} onRetry={retry} />}
408408
>
409-
{children}
409+
{/* objectstack#26838 — the apps a launcher offers (Home's tiles, the app
410+
switcher, the `app:launcher` block) are the `/me/apps` answer, which
411+
drops an app the user's permission sets hide. One read for Home and
412+
every app, so this common ancestor holds it, beside the answer above. */}
413+
<LauncherAppsProvider endpoint={`${serverUrl}/api/v1/me/apps`} fetcher={authFetch}>
414+
{children}
415+
</LauncherAppsProvider>
410416
</MePermissionsProvider>
411417
);
412418
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26838 — the ⌘K palette's "Switch app" group offers no app the
5+
* user's permission sets hide (`tabPermissions: hidden`): it is narrowed by
6+
* the same `GET /me/apps` answer as Home's tiles and the app switcher. The
7+
* palette keeps its own rule otherwise — every active app, a `hidden` one
8+
* (the avatar-menu kind) included.
9+
*
10+
* Under the real `LauncherAppsProvider` fed a `/me/apps` double, the real
11+
* `CommandPaletteProvider` opened by its `?palette=1` deep link, and a real
12+
* `I18nProvider` in `en` — the `CommandPalette.navMatch-11812` harness.
13+
*/
14+
15+
import * as React from 'react';
16+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
17+
import { render, waitFor, cleanup } from '@testing-library/react';
18+
import { MemoryRouter } from 'react-router-dom';
19+
import { createI18n, I18nProvider } from '@object-ui/i18n';
20+
21+
const state = vi.hoisted(() => ({ apps: [] as Array<Record<string, unknown>> }));
22+
23+
vi.mock('@object-ui/auth', async (importOriginal) => ({
24+
...(await importOriginal<Record<string, unknown>>()),
25+
useAuth: () => ({ user: { id: 'u1' }, activeOrganization: null }),
26+
}));
27+
// The provider reads the app list from the metadata cache; the rest of the
28+
// cache is the real (empty) one the palette's other groups read.
29+
vi.mock('../providers/MetadataProvider', async (importOriginal) => {
30+
const actual = await importOriginal<typeof import('../providers/MetadataProvider')>();
31+
return {
32+
...actual,
33+
useMetadata: () => ({ ...actual.useMetadata(), apps: state.apps, loading: false }),
34+
};
35+
});
36+
37+
import { CommandPalette } from './CommandPalette';
38+
import { CommandPaletteProvider } from '../context/CommandPaletteProvider';
39+
import { LauncherAppsProvider } from '../hooks/useLauncherApps';
40+
41+
const SHOWCASE = { name: 'showcase_app', label: 'Showcase', navigation: [] };
42+
const CRM = { name: 'crm_app', label: 'CRM', navigation: [] };
43+
const OPS = { name: 'ops_app', label: 'Ops', navigation: [] };
44+
const ACCOUNT = { name: 'account', label: 'Account', hidden: true, navigation: [] };
45+
46+
/** A `/me/apps` double: the listed names, or a bare status. */
47+
function meApps(answer: string[] | number) {
48+
return vi.fn(async (_url: unknown) =>
49+
typeof answer === 'number'
50+
? new Response('{}', { status: answer })
51+
: new Response(JSON.stringify({ apps: answer.map((name) => ({ name })) }), { status: 200 }),
52+
);
53+
}
54+
55+
function mount(fetcher: ReturnType<typeof meApps>) {
56+
render(
57+
<I18nProvider instance={createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false })} persistLanguage={false}>
58+
<MemoryRouter initialEntries={['/apps/showcase_app?palette=1']}>
59+
<LauncherAppsProvider endpoint="/me-apps-double" fetcher={fetcher as unknown as typeof fetch}>
60+
<CommandPaletteProvider>
61+
<CommandPalette
62+
apps={state.apps}
63+
activeApp={SHOWCASE}
64+
objects={[]}
65+
onAppChange={() => {}}
66+
dataSource={{ find: vi.fn(async () => ({ data: [] })), searchAll: vi.fn(async () => ({ hits: [] })) }}
67+
/>
68+
</CommandPaletteProvider>
69+
</LauncherAppsProvider>
70+
</MemoryRouter>
71+
</I18nProvider>,
72+
);
73+
if (!document.querySelector('[cmdk-input]')) throw new Error('the palette did not open');
74+
}
75+
76+
/** The visible text of every app item cmdk rendered. */
77+
function shownApps(): string[] {
78+
return Array.from(document.querySelectorAll('[cmdk-item][data-value^="app "]')).map((el) =>
79+
(el.textContent ?? '').replace(/Current$/, '').trim(),
80+
);
81+
}
82+
83+
beforeEach(() => {
84+
state.apps = [SHOWCASE, CRM, OPS, ACCOUNT];
85+
});
86+
87+
afterEach(() => cleanup());
88+
89+
describe('objectstack#26838 — the palette offers no app a permission set hides', () => {
90+
it('an app /me/apps does not list is not a "Switch app" item', async () => {
91+
const fetcher = meApps(['showcase_app', 'crm_app', 'account']);
92+
mount(fetcher);
93+
await waitFor(() => expect(shownApps()).toEqual(['Showcase', 'CRM', 'Account']));
94+
expect(fetcher).toHaveBeenCalledTimes(1);
95+
});
96+
97+
it('CONTROL — a 404 (no security plugin) offers every active app, as before', async () => {
98+
const fetcher = meApps(404);
99+
mount(fetcher);
100+
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
101+
await waitFor(() => expect(shownApps()).toEqual(['Showcase', 'CRM', 'Ops', 'Account']));
102+
});
103+
});

‎ui/packages/app-shell/src/chrome/CommandPalette.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import { fetchPackages, type PkgEntry } from '../views/studio-design/packages-io
7373
import { isPackageLessItem, studioOrgScopePath } from '../views/studio-design/studioScope.js';
7474
import { DESIGNER_SURFACE_PARAM, formatSurfaceParam } from '../views/metadata-admin/nav-selection.js';
7575
import { flattenNavigation, useAppReportEntries } from '../views/appReportIndex.js';
76+
import { useServedApps } from '../hooks/useLauncherApps.js';
7677

7778
/** The palette of the active app: the frame `ConsoleLayout` draws for `/apps/APP`. */
7879
interface AppCommandPaletteProps {
@@ -251,7 +252,10 @@ function CommandPaletteBody(props: CommandPaletteProps & { gates: NavMemberGates
251252
const appReports = useAppReportEntries(activeApp, open && !inStudio, gates).entries.filter(
252253
(entry) => !navReportNames.has(entry.name) && matches(entry.label, entry.name),
253254
);
254-
const switchableApps = apps.filter((a) => a.active !== false);
255+
// Every active app, `hidden` ones included, minus an app the user's
256+
// permission sets hide: the palette offers none that Home's tiles and the
257+
// switcher withhold for that reason (objectstack#26838).
258+
const switchableApps = useServedApps(apps.filter((a) => a.active !== false));
255259
const appItems =
256260
switchableApps.length > 1
257261
? switchableApps.filter((app) => matches(resolveKeyedI18nLabel(app.label, t), app.name))

‎ui/packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
import { buildExpressionUser } from '../providers/expressionUser.js';
3434
import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent.js';
3535
import { useHomePath } from '../hooks/useHomePath.js';
36+
import { useLauncherApps } from '../hooks/useLauncherApps.js';
3637
import { useSignedInUserLocale } from '../hooks/useUserLocale.js';
3738
import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveFormViewChrome, selectRecordFormView, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js';
3839
import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view';
@@ -336,8 +337,11 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
336337
// ⛔ `hidden` only — never `_unpublished` (objectstack#6955 / objectstack-ai/objectstack#4829 A1). The
337338
// publish gate is enforced server-side; duplicating it here would strand a
338339
// builder on their own unpublished app.
339-
const activeApps = apps.filter((a: any) => a.active !== false);
340-
const launcherApps = activeApps.filter((a: any) => a.hidden !== true);
340+
//
341+
// objectstack#26838 — and, like Home's tiles and the switcher, never on an
342+
// app the user's permission sets hide (`tabPermissions: hidden`, the
343+
// `/me/apps` answer); the route lookup below still finds such an app.
344+
const { apps: launcherApps } = useLauncherApps(apps);
341345
// ADR-0048 (A) — the route segment is the package id; resolve by it,
342346
// falling back to the app name (legacy/alias URL).
343347
// Built-in pseudo-routes under /apps/* that are NOT metadata apps (create-app,

0 commit comments

Comments
 (0)