Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/26838-launcher-hidden-tabs.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 8 additions & 2 deletions ui/apps/console/src/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -406,7 +406,13 @@ export function ConsolePermissionsScope({ children }: { children: ReactNode }) {
// error + retry affordance all along.
errorFallback={(err, retry) => <LoadingScreen error={err.message} onRetry={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. */}
<LauncherAppsProvider endpoint={`${serverUrl}/api/v1/me/apps`} fetcher={authFetch}>
{children}
</LauncherAppsProvider>
</MePermissionsProvider>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> }));

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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<typeof import('../providers/MetadataProvider')>();
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<typeof meApps>) {
render(
<I18nProvider instance={createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false })} persistLanguage={false}>
<MemoryRouter initialEntries={['/apps/showcase_app?palette=1']}>
<LauncherAppsProvider endpoint="/me-apps-double" fetcher={fetcher as unknown as typeof fetch}>
<CommandPaletteProvider>
<CommandPalette
apps={state.apps}
activeApp={SHOWCASE}
objects={[]}
onAppChange={() => {}}
dataSource={{ find: vi.fn(async () => ({ data: [] })), searchAll: vi.fn(async () => ({ hits: [] })) }}
/>
</CommandPaletteProvider>
</LauncherAppsProvider>
</MemoryRouter>
</I18nProvider>,
);
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']));
});
});
6 changes: 5 additions & 1 deletion ui/packages/app-shell/src/chrome/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down
8 changes: 6 additions & 2 deletions ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading