From c31701aa1617dff82c38c2dbd9d6f80417c77630 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 00:56:45 +0000 Subject: [PATCH 1/4] fix(app-shell): launcher surfaces narrow the app list by the user's /me/apps answer Home's tiles, the app switcher menu and the app:launcher block listed GET /meta/app, which still serves an app a permission set hides (tabPermissions: hidden); the hide is applied only in GET /me/apps. A LauncherAppsProvider, mounted in the console's ConsolePermissionsScope, reads /me/apps once per user and useLauncherApps narrows each surface's list by its app names. No provider, a 404 or a failed read narrows nothing. Claude-Session: https://claude.ai/code/session_01JQwxbYwBbR8jCpWPE5Kosz Co-authored-by: Claude --- .changeset/26838-launcher-hidden-tabs.md | 21 ++ ui/apps/console/src/AppContent.tsx | 10 +- .../app-shell/src/console/home/HomePage.tsx | 16 +- ui/packages/app-shell/src/hooks/index.ts | 6 + .../app-shell/src/hooks/useLauncherApps.tsx | 212 ++++++++++++++++++ ui/packages/app-shell/src/index.ts | 4 +- .../app-shell/src/layout/AppSwitcher.tsx | 8 +- .../src/views/app-launcher-renderer.tsx | 16 +- 8 files changed, 278 insertions(+), 15 deletions(-) create mode 100644 .changeset/26838-launcher-hidden-tabs.md create mode 100644 ui/packages/app-shell/src/hooks/useLauncherApps.tsx 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/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/hooks/index.ts b/ui/packages/app-shell/src/hooks/index.ts index b74b6b7e178..b16ec19f182 100644 --- a/ui/packages/app-shell/src/hooks/index.ts +++ b/ui/packages/app-shell/src/hooks/index.ts @@ -63,6 +63,12 @@ export { } from './useUrlOverlay.js'; export { useTenancyPosture } from './useTenancyPosture.js'; export { useHomePath } from './useHomePath.js'; +export { + LauncherAppsProvider, + useLauncherApps, + 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..ce498f64d91 --- /dev/null +++ b/ui/packages/app-shell/src/hooks/useLauncherApps.tsx @@ -0,0 +1,212 @@ +// 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); + const openable = filterActiveApps(apps); + if (state?.status !== 'ready') return { apps: openable, pending: state?.status === 'pending' }; + const { served, asked } = state.answer; + return { + apps: openable.filter((app) => { + const name = app?.name; + return typeof name !== 'string' || served.has(name) || !asked.has(name); + }), + pending: false, + }; +} diff --git a/ui/packages/app-shell/src/index.ts b/ui/packages/app-shell/src/index.ts index 44f9fbaf060..8c3696cf36d 100644 --- a/ui/packages/app-shell/src/index.ts +++ b/ui/packages/app-shell/src/index.ts @@ -237,8 +237,10 @@ export { useResponsiveSidebar, useActionModal, useHomePath, + LauncherAppsProvider, + useLauncherApps, } 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/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 (