diff --git a/workspaces/intelligent-assistant/.changeset/brave-ia-permissions-gate.md b/workspaces/intelligent-assistant/.changeset/brave-ia-permissions-gate.md new file mode 100644 index 00000000000..481fdd19b66 --- /dev/null +++ b/workspaces/intelligent-assistant/.changeset/brave-ia-permissions-gate.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-intelligent-assistant': minor +--- + +Gate Intelligent Assistant UI by consolidated RBAC permissions (`intelligent-assistant.chat`, `intelligent-assistant.notebooks`, `intelligent-assistant.mcp.tools`, `intelligent-assistant.skills`). Features are hidden when access is denied instead of showing permission-denied screens or read-only MCP mode. diff --git a/workspaces/intelligent-assistant/.eslintignore b/workspaces/intelligent-assistant/.eslintignore index cbadeb3b825..16d2fe29071 100644 --- a/workspaces/intelligent-assistant/.eslintignore +++ b/workspaces/intelligent-assistant/.eslintignore @@ -1,4 +1,5 @@ playwright.config.ts +playwright.config.rbac.ts e2e-tests/ !.eslintrc.js !.prettierrc.js \ No newline at end of file diff --git a/workspaces/intelligent-assistant/app-config.e2e-rbac.yaml b/workspaces/intelligent-assistant/app-config.e2e-rbac.yaml new file mode 100644 index 00000000000..eeb9aa6572a --- /dev/null +++ b/workspaces/intelligent-assistant/app-config.e2e-rbac.yaml @@ -0,0 +1,7 @@ +# Playwright overlay: enable the permission framework so the UI calls +# POST /api/permission/authorize (required for RBAC e2e route mocks). +permission: + enabled: true + rbac: + policies-csv-file: ./rbac-policy.e2e.csv + policyFileReload: true diff --git a/workspaces/intelligent-assistant/e2e-tests/lightspeed.permissions.test.ts b/workspaces/intelligent-assistant/e2e-tests/lightspeed.permissions.test.ts new file mode 100644 index 00000000000..eb25dc13ef1 --- /dev/null +++ b/workspaces/intelligent-assistant/e2e-tests/lightspeed.permissions.test.ts @@ -0,0 +1,116 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect, type Browser } from '@playwright/test'; + +import { IaRbacPermissionsPage } from './pages/IaRbacPermissionsPage'; +import { + IA_PERMISSIONS_ALL_ALLOWED, + waitForIaPermissionAuthorize, + type IaPermissionMatrix, +} from './utils/iaPermissionsE2e'; +import { bootstrapLightspeedRbacE2ePage } from './utils/lightspeedE2eSetup'; + +async function withPermissionScenario( + browser: Browser, + matrix: IaPermissionMatrix, + run: (permissions: IaRbacPermissionsPage) => Promise, +): Promise { + const boot = await bootstrapLightspeedRbacE2ePage(browser, matrix); + const permissions = new IaRbacPermissionsPage(boot.page, boot.translations); + try { + await boot.page.goto('/'); + await waitForIaPermissionAuthorize(boot.page); + await run(permissions); + } finally { + await boot.page.context().close(); + } +} + +test.describe('Intelligent assistant permissions', () => { + test.describe.configure({ mode: 'serial' }); + + test('shows FAB and chat and notebooks tabs', async ({ browser }) => { + await withPermissionScenario( + browser, + { chat: true, notebooks: true, mcp: false }, + async permissions => { + await permissions.expectFabVisible(); + await permissions.openFromFab(); + await permissions.expectChatAndNotebooksTabsVisible(); + await expect(permissions.newChatButton()).toBeVisible(); + }, + ); + }); + + test('shows chat-only layout without notebooks tab', async ({ browser }) => { + await withPermissionScenario( + browser, + { chat: true, notebooks: false, mcp: false }, + async permissions => { + await permissions.expectFabVisible(); + await permissions.openFromFab(); + await permissions.expectChatOnlyLayout(); + }, + ); + }); + + test('shows notebooks-only layout without chat tab', async ({ browser }) => { + await withPermissionScenario( + browser, + { chat: false, notebooks: true, mcp: false }, + async permissions => { + await permissions.expectFabVisible(); + await permissions.openFromFab(); + await permissions.expectNotebooksOnlyLayout(); + }, + ); + }); + + test('hides FAB when chat and notebooks are denied', async ({ browser }) => { + await withPermissionScenario( + browser, + { chat: false, notebooks: false, mcp: false }, + async permissions => { + await permissions.expectFabHidden(); + }, + ); + }); + + test('shows MCP settings in header menu when allowed', async ({ + browser, + }) => { + await withPermissionScenario( + browser, + IA_PERMISSIONS_ALL_ALLOWED, + async permissions => { + await permissions.expectMcpMenuVisible(); + }, + ); + }); + + test('hides MCP settings from header menu when denied', async ({ + browser, + }) => { + await withPermissionScenario( + browser, + { ...IA_PERMISSIONS_ALL_ALLOWED, mcp: false }, + async permissions => { + await permissions.expectMcpMenuHidden(); + }, + ); + }); +}); diff --git a/workspaces/intelligent-assistant/e2e-tests/pages/IaRbacPermissionsPage.ts b/workspaces/intelligent-assistant/e2e-tests/pages/IaRbacPermissionsPage.ts new file mode 100644 index 00000000000..e3557a532d3 --- /dev/null +++ b/workspaces/intelligent-assistant/e2e-tests/pages/IaRbacPermissionsPage.ts @@ -0,0 +1,118 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, type Locator, type Page } from '@playwright/test'; + +import type { LightspeedMessages } from '../utils/translations'; +import { openChatbot } from './LightspeedPage'; + +/** + * Intelligent Assistant permission gating: FAB visibility, tab layout, and MCP menu. + */ +export class IaRbacPermissionsPage { + constructor( + private readonly page: Page, + private readonly t: LightspeedMessages, + ) {} + + fabButton(): Locator { + return this.page.getByRole('button', { name: this.t['tooltip.fab.open'] }); + } + + chatbotRegion(): Locator { + return this.page.getByLabel('Chatbot', { exact: true }); + } + + newChatButton(): Locator { + return this.page.getByRole('button', { name: this.t['button.newChat'] }); + } + + chatTab(): Locator { + return this.chatbotRegion().getByRole('tab', { name: this.t['tabs.chat'] }); + } + + notebooksTab(): Locator { + return this.chatbotRegion().getByRole('tab', { + name: this.t['tabs.notebooks'], + }); + } + + notebooksEmptyTitle(): Locator { + return this.page.getByText(this.t['notebooks.empty.title']); + } + + mcpSettingsMenuItem(): Locator { + return this.page.getByRole('menuitem', { + name: this.t['settings.mcp.label'], + }); + } + + async expectFabVisible(): Promise { + await expect(this.fabButton()).toBeVisible(); + } + + async expectFabHidden(): Promise { + await expect(this.fabButton()).toHaveCount(0); + } + + async openFromFab(): Promise { + await openChatbot(this.page, this.t); + await expect(this.chatbotRegion()).toBeVisible(); + } + + async expectChatAndNotebooksTabsVisible(): Promise { + await expect(this.chatTab()).toBeVisible(); + await expect(this.notebooksTab()).toBeVisible(); + } + + async expectChatOnlyLayout(): Promise { + await expect(this.notebooksTab()).toBeHidden(); + await expect(this.newChatButton()).toBeVisible(); + await expect(this.notebooksEmptyTitle()).not.toBeVisible(); + } + + async expectNotebooksOnlyLayout(): Promise { + await expect(this.chatTab()).toBeHidden(); + await expect(this.newChatButton()).not.toBeVisible(); + await expect(this.notebooksEmptyTitle()).toBeVisible(); + } + + async openOptionsMenu(): Promise { + await this.page + .getByRole('button', { name: this.t['aria.options.label'] }) + .click(); + } + + async expectMcpSettingsVisible(): Promise { + await expect(this.mcpSettingsMenuItem()).toBeVisible(); + } + + async expectMcpSettingsHidden(): Promise { + await expect(this.mcpSettingsMenuItem()).toHaveCount(0); + } + + async expectMcpMenuVisible(): Promise { + await this.openFromFab(); + await this.openOptionsMenu(); + await this.expectMcpSettingsVisible(); + } + + async expectMcpMenuHidden(): Promise { + await this.openFromFab(); + await this.openOptionsMenu(); + await this.expectMcpSettingsHidden(); + } +} diff --git a/workspaces/intelligent-assistant/e2e-tests/utils/iaPermissionsE2e.ts b/workspaces/intelligent-assistant/e2e-tests/utils/iaPermissionsE2e.ts new file mode 100644 index 00000000000..8e41f04453e --- /dev/null +++ b/workspaces/intelligent-assistant/e2e-tests/utils/iaPermissionsE2e.ts @@ -0,0 +1,132 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { BrowserContext, Page } from '@playwright/test'; + +export type IaPermissionMatrix = { + chat: boolean; + notebooks: boolean; + mcp: boolean; +}; + +export const IA_PERMISSIONS_ALL_ALLOWED: IaPermissionMatrix = { + chat: true, + notebooks: true, + mcp: true, +}; + +const IA_PERMISSION_AUTHORIZE_ROUTE = '**/api/permission/authorize'; + +const IA_PERMISSION_NAMES = { + chat: 'intelligent-assistant.chat', + notebooks: 'intelligent-assistant.notebooks', + mcp: 'intelligent-assistant.mcp.tools', + skills: 'intelligent-assistant.skills', +} as const; + +const IA_PERMISSION_NAME_SET = new Set( + Object.values(IA_PERMISSION_NAMES), +); + +function isIaPermissionName(name: string | undefined): boolean { + return name !== undefined && IA_PERMISSION_NAME_SET.has(name); +} + +function isIaPermissionAllowed( + permissionName: string | undefined, + matrix: IaPermissionMatrix, +): boolean { + switch (permissionName) { + case IA_PERMISSION_NAMES.chat: + return matrix.chat; + case IA_PERMISSION_NAMES.notebooks: + return matrix.notebooks; + case IA_PERMISSION_NAMES.mcp: + return matrix.mcp; + case IA_PERMISSION_NAMES.skills: + return false; + default: + return true; + } +} + +type AuthorizeRequestItem = { + id: string; + permission?: { name?: string }; +}; + +/** + * Install before the first page is created on the context. Intercepts IA + * permission checks while allowing other authorize items through as ALLOW. + */ +export async function installIaPermissionsMock( + context: BrowserContext, + matrix: IaPermissionMatrix, +): Promise { + const matrixRef = { current: matrix }; + + await context.route(IA_PERMISSION_AUTHORIZE_ROUTE, async route => { + if (route.request().method() !== 'POST') { + await route.continue(); + return; + } + + let body: { items?: AuthorizeRequestItem[] }; + try { + body = route.request().postDataJSON() as { + items?: AuthorizeRequestItem[]; + }; + } catch { + await route.continue(); + return; + } + + const items = body?.items ?? []; + const touchesIaPermission = items.some(item => + isIaPermissionName(item.permission?.name), + ); + + if (!touchesIaPermission) { + await route.continue(); + return; + } + + const activeMatrix = matrixRef.current; + await route.fulfill({ + json: { + items: items.map(item => { + const permissionName = item.permission?.name; + const result = + isIaPermissionName(permissionName) && + !isIaPermissionAllowed(permissionName, activeMatrix) + ? 'DENY' + : 'ALLOW'; + return { id: item.id, result }; + }), + }, + }); + }); +} + +/** Wait until the FAB has resolved IA permission checks on the catalog page. */ +export async function waitForIaPermissionAuthorize(page: Page): Promise { + await page.waitForResponse( + response => + response.url().includes('/api/permission/authorize') && + response.request().method() === 'POST', + { timeout: 30_000 }, + ); +} diff --git a/workspaces/intelligent-assistant/e2e-tests/utils/lightspeedE2eSetup.ts b/workspaces/intelligent-assistant/e2e-tests/utils/lightspeedE2eSetup.ts index 189dda499ef..cfe07043773 100644 --- a/workspaces/intelligent-assistant/e2e-tests/utils/lightspeedE2eSetup.ts +++ b/workspaces/intelligent-assistant/e2e-tests/utils/lightspeedE2eSetup.ts @@ -28,6 +28,11 @@ import { mockQuery, mockShields, } from './devMode'; +import { + installIaPermissionsMock, + waitForIaPermissionAuthorize, + type IaPermissionMatrix, +} from './iaPermissionsE2e'; import { getTranslations, type LightspeedMessages } from './translations'; /** Default user message used by the shared query mock in Lightspeed e2e. */ @@ -67,6 +72,18 @@ async function loginAsGuest(page: Page) { } } } + +async function setupLightspeedApiMocks(page: Page) { + await mockModels(page, models); + await mockConversations(page); + await mockChatHistory(page); + await mockQuery(page, LIGHTSPEED_E2E_DEFAULT_BOT_QUERY, conversations); + await mockShields(page, mockedShields); + await mockMcpServers(page); + await mockFeedbackStatus(page); + await mockNotebookLightspeedBackend(page); +} + /** * One logged-in Lightspeed session with the same dev-mode mocks as the legacy * monolithic suite. Each Playwright test file should call this from `beforeAll`. @@ -79,14 +96,7 @@ export async function bootstrapLightspeedE2ePage( const locale = await page.evaluate(() => globalThis.navigator.language); const translations = getTranslations(locale); - await mockModels(page, models); - await mockConversations(page); - await mockChatHistory(page); - await mockQuery(page, LIGHTSPEED_E2E_DEFAULT_BOT_QUERY, conversations); - await mockShields(page, mockedShields); - await mockMcpServers(page); - await mockFeedbackStatus(page); - await mockNotebookLightspeedBackend(page); + await setupLightspeedApiMocks(page); await page.goto('/'); await loginAsGuest(page); @@ -96,3 +106,28 @@ export async function bootstrapLightspeedE2ePage( return { page, locale, translations }; } + +/** + * Guest session with IA API mocks and a fixed permission matrix. + * Installs the authorize mock on the browser context before any navigation. + */ +export async function bootstrapLightspeedRbacE2ePage( + browser: Browser, + permissions: IaPermissionMatrix, +): Promise { + const context = await browser.newContext({ locale: 'en-US' }); + await installIaPermissionsMock(context, permissions); + + const page = await context.newPage(); + const translations = getTranslations('en'); + + await setupLightspeedApiMocks(page); + + await page.goto('/'); + await loginAsGuest(page); + await switchToLocale(page, 'en'); + await page.reload(); + await waitForIaPermissionAuthorize(page); + + return { page, locale: 'en', translations }; +} diff --git a/workspaces/intelligent-assistant/package.json b/workspaces/intelligent-assistant/package.json index fba36d50c48..a4bb8534c26 100644 --- a/workspaces/intelligent-assistant/package.json +++ b/workspaces/intelligent-assistant/package.json @@ -20,8 +20,9 @@ "test": "backstage-cli repo test", "test:all": "backstage-cli repo test --coverage", "test:e2e": "yarn test:e2e:all", - "test:e2e:legacy": "APP_MODE=legacy playwright test", - "test:e2e:nfs": "APP_MODE=nfs playwright test", + "test:e2e:rbac": "playwright test --config playwright.config.rbac.ts", + "test:e2e:legacy": "APP_MODE=legacy playwright test && APP_MODE=legacy yarn test:e2e:rbac", + "test:e2e:nfs": "APP_MODE=nfs playwright test && APP_MODE=nfs yarn test:e2e:rbac", "test:e2e:all": "yarn test:e2e:legacy && yarn test:e2e:nfs", "test:e2e:ci": "yarn test:e2e:all", "playwright": "bash -c 'if [[ $@ == test ]]; then yarn test:e2e:all; else npx playwright \"$@\"; fi' _", diff --git a/workspaces/intelligent-assistant/playwright.config.rbac.ts b/workspaces/intelligent-assistant/playwright.config.rbac.ts new file mode 100644 index 00000000000..aff1bd6ba3d --- /dev/null +++ b/workspaces/intelligent-assistant/playwright.config.rbac.ts @@ -0,0 +1,77 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { defineConfig } from '@playwright/test'; + +const appMode = process.env.APP_MODE || 'legacy'; +const startCommand = appMode === 'legacy' ? 'yarn start:legacy' : 'yarn start'; +const baseConfig = `${__dirname}/app-config.yaml`; +const rbacE2eConfig = `${__dirname}/app-config.e2e-rbac.yaml`; + +/** + * Isolated Playwright config for RBAC permission gating e2e. + * Starts the app with permission.enabled so authorize calls hit the network + * and the per-scenario route mock can apply deny matrices. + */ +export default defineConfig({ + timeout: 3 * 60 * 1000, + + expect: { + timeout: 15_000, + }, + + webServer: process.env.PLAYWRIGHT_URL + ? [] + : { + command: `${startCommand} --config ${baseConfig} --config ${rbacE2eConfig}`, + port: 3000, + reuseExistingServer: !process.env.CI, + cwd: __dirname, + timeout: 4 * 60 * 1000, + env: { + NOTEBOOKS_ENABLED: 'true', + NOTEBOOKS_QUERY_MODEL: 'gpt-4', + NOTEBOOKS_QUERY_PROVIDER_ID: 'openai', + }, + }, + + retries: process.env.CI ? 2 : 0, + + reporter: [ + [ + 'html', + { + open: 'never', + outputFolder: `e2e-test-report-rbac-${appMode}`, + }, + ], + ], + + use: { + baseURL: process.env.PLAYWRIGHT_URL ?? 'http://localhost:3000', + screenshot: 'only-on-failure', + trace: 'on-first-retry', + permissions: ['clipboard-read', 'clipboard-write'], + channel: 'chrome', + locale: 'en-US', + }, + + outputDir: `node_modules/.cache/e2e-test-results-rbac-${appMode}`, + + testDir: 'e2e-tests', + testMatch: '**/lightspeed.permissions.test.ts', + fullyParallel: false, +}); diff --git a/workspaces/intelligent-assistant/playwright.config.ts b/workspaces/intelligent-assistant/playwright.config.ts index 2555d362a72..f2443d2c3f8 100644 --- a/workspaces/intelligent-assistant/playwright.config.ts +++ b/workspaces/intelligent-assistant/playwright.config.ts @@ -64,6 +64,7 @@ export default defineConfig({ projects: LOCALES.map(locale => ({ name: locale, + testIgnore: '**/lightspeed.permissions.test.ts', use: { channel: 'chrome' as const, locale, diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/README.md b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/README.md index 8e8d38f232d..858a37e1953 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/README.md +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/README.md @@ -79,9 +79,11 @@ All nested keys (`servicePort`, `systemPrompt`, `prompts`, `mcpServers`, `notebo #### 4. RBAC policy names -Update permission names in your `rbac-policy.csv`: +Update permission names in your `rbac-policy.csv`. Intelligent Assistant uses four +feature-level permissions. In CSV policies, grant each with action **`use`** (not +`read`, `create`, `update`, or `manage`). -| Before | After | +| Before | After (CSV action: `use`) | | -------------------------- | --------------------------------- | | `lightspeed.chat.read` | `intelligent-assistant.chat` | | `lightspeed.chat.create` | `intelligent-assistant.chat` | @@ -91,6 +93,16 @@ Update permission names in your `rbac-policy.csv`: | `lightspeed.mcp.read` | `intelligent-assistant.mcp.tools` | | `lightspeed.mcp.manage` | `intelligent-assistant.mcp.tools` | +| Permission | Backend scope | +| --------------------------------- | ------------------------------------------------ | +| `intelligent-assistant.chat` | Chat APIs (models, conversations, prompts, etc.) | +| `intelligent-assistant.notebooks` | Notebooks `/v1/*` APIs | +| `intelligent-assistant.mcp.tools` | MCP server list and settings APIs | +| `intelligent-assistant.skills` | Skills list API | + +The frontend plugin gates UI with the same four permissions; see +[Intelligent Assistant Frontend README](../intelligent-assistant/README.md#permission-framework-support). + #### 5. OFS dynamic plugin configuration The top-level plugin key, route path, and drawer `config.id` change. `importName` values are **unchanged**: @@ -333,16 +345,14 @@ The Intelligent Assistant Backend plugin has support for the permission framewor ```CSV p, role:default/team_a, intelligent-assistant.chat, use, allow - -# Required for Notebooks feature (if enabled) p, role:default/team_a, intelligent-assistant.notebooks, use, allow - -# Required for MCP server management (if configured) p, role:default/team_a, intelligent-assistant.mcp.tools, use, allow - -# Required for Skills feature (if enabled) p, role:default/team_a, intelligent-assistant.skills, use, allow +# Often required when MCP tools query the catalog (see workspace rbac-policy.csv) +p, role:default/team_a, catalog.entity.read, read, allow +p, role:default/team_a, catalog.location.read, read, allow + g, user:default/, role:default/team_a ``` @@ -460,12 +470,8 @@ When enabled, Notebooks exposes the following REST API endpoints: #### Permission Framework Support for Notebooks -When RBAC is enabled, users need the following permissions to use Notebooks: - -```CSV -p, role:default/team_a, intelligent-assistant.notebooks, use, allow - -g, user:default/, role:default/team_a -``` - -Add this to your `rbac-policy.csv` file along with the existing intelligent-assistant permissions. +When RBAC is enabled, Notebooks backend routes require +`intelligent-assistant.notebooks` with action `use`. Include that line in the +[Permission Framework Support](#permission-framework-support) policy block above +(along with `intelligent-assistant.chat` if users should open the assistant from +the FAB). diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/report-alpha.api.md b/workspaces/intelligent-assistant/plugins/intelligent-assistant/report-alpha.api.md index 0097667c323..f9c398ea3c4 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/report-alpha.api.md +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/report-alpha.api.md @@ -122,11 +122,6 @@ export const intelligentAssistantTranslationRef: TranslationRef< readonly 'conversation.rename.confirm.title': string; readonly 'conversation.rename.confirm.action': string; readonly 'conversation.rename.placeholder': string; - readonly 'permission.required.title': string; - readonly 'permission.required.description': string; - readonly 'permission.subject.plugin': string; - readonly 'permission.subject.notebooks': string; - readonly 'permission.notebooks.goBack': string; readonly 'lcore.notConfigured.title': string; readonly 'lcore.notConfigured.description': string; readonly 'lcore.notConfigured.developerLightspeedDocs': string; @@ -198,7 +193,6 @@ export const intelligentAssistantTranslationRef: TranslationRef< readonly 'modal.title.preview': string; readonly 'modal.title.edit': string; readonly 'icon.lightspeed.alt': string; - readonly 'icon.permissionRequired.alt': string; readonly 'message.options.label': string; readonly 'file.upload.error.alreadyExists': string; readonly 'file.upload.error.multipleFiles': string; @@ -234,7 +228,6 @@ export const intelligentAssistantTranslationRef: TranslationRef< readonly 'mcp.settings.title': string; readonly 'mcp.settings.selectedCount': string; readonly 'mcp.settings.closeAriaLabel': string; - readonly 'mcp.settings.readOnlyAccess': string; readonly 'mcp.settings.tableAriaLabel': string; readonly 'mcp.settings.enabled': string; readonly 'mcp.settings.name': string; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx index 3166efad5bf..07c209763f2 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx @@ -34,7 +34,6 @@ import { useLocation, useMatch, useNavigate } from 'react-router-dom'; import { configApiRef, useApi } from '@backstage/core-plugin-api'; -import Button from '@mui/material/Button'; import { styled } from '@mui/material/styles'; import Tab from '@mui/material/Tab'; import Tabs from '@mui/material/Tabs'; @@ -88,10 +87,11 @@ import { useBackstageUserIdentity, useConversationMessages, useConversations, + useIaChatPermission, + useIaMcpToolsPermission, + useIaNotebooksPermission, useIsMobile, useLastOpenedConversation, - useLightspeedDeletePermission, - useLightspeedNotebooksPermission, useNotebookConversationIds, useNotebookSession, useNotebookSessions, @@ -104,7 +104,6 @@ import { useDeleteNotebook } from '../hooks/notebooks/useDeleteNotebook'; import { useNotebookDocuments } from '../hooks/notebooks/useNotebookDocuments'; import { useRenameNotebookWithAlert } from '../hooks/notebooks/useRenameNotebookWithAlert'; import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext'; -import { useLightspeedUpdatePermission } from '../hooks/useLightspeedUpdatePermission'; import { useTranslation } from '../hooks/useTranslation'; import { useWelcomePrompts } from '../hooks/useWelcomePrompts'; import { ConversationSummary, NotebookSession } from '../types'; @@ -134,7 +133,6 @@ import { SidebarCollapseIcon, SidebarExpandIcon, } from './notebooks/SidebarCollapseIcon'; -import PermissionRequiredState from './PermissionRequiredState'; import { RenameConversationModal } from './RenameConversationModal'; import { ToastAlertGroup } from './ToastAlertGroup'; @@ -581,7 +579,6 @@ export const LightspeedChat = ({ const isOnNotebookRoute = Boolean( notebooksRouteMatch || notebookViewRouteMatch, ); - const shouldShowTabs = notebooksEnabled || isOnNotebookRoute; const { displayMode, setDisplayMode, @@ -617,16 +614,29 @@ export const LightspeedChat = ({ } return 0; }); - const { - allowed: hasNotebooksAccess, - loading: notebooksPermissionLoading, - iaNotebooksPermissionName, - } = useLightspeedNotebooksPermission(); + const { allowed: hasChatAccess, loading: chatPermissionLoading } = + useIaChatPermission(); + const chatPermissionResolved = !chatPermissionLoading && hasChatAccess; + const { allowed: hasNotebooksAccess, loading: notebooksPermissionLoading } = + useIaNotebooksPermission(); const notebooksPermissionResolved = !notebooksPermissionLoading && hasNotebooksAccess; + const canShowNotebooks = + notebooksPermissionResolved && (notebooksEnabled || isOnNotebookRoute); + const hasChatTab = chatPermissionResolved; + const hasNotebooksTab = canShowNotebooks; + const hasBothTabs = hasChatTab && hasNotebooksTab; + const shouldShowTabs = hasBothTabs; + const selectedTabIndex = hasBothTabs ? activeTab : 0; + const { allowed: hasMcpToolsAccess, loading: mcpToolsPermissionLoading } = + useIaMcpToolsPermission(); + const mcpToolsPermissionResolved = + !mcpToolsPermissionLoading && hasMcpToolsAccess; const { data: notebookConversationIdsArray = [] } = - useNotebookConversationIds(); + useNotebookConversationIds( + chatPermissionResolved || notebooksPermissionResolved, + ); const { data: notebooks = [], refetch: refetchNotebooks } = useNotebookSessions(notebooksPermissionResolved); const hasNotebooks = notebooks.length > 0; @@ -692,32 +702,67 @@ export const LightspeedChat = ({ const wasStoppedByUserRef = useRef(false); const { isReady, lastOpenedId, setLastOpenedId, clearLastOpenedId } = useLastOpenedConversation(user); - const showChatPanel = activeTab === 0; + const showChatPanel = hasChatTab && (activeTab === 0 || !hasNotebooksTab); const showNotebooksPanel = - (notebooksEnabled || isOnNotebookRoute) && activeTab !== 0; + hasNotebooksTab && (activeTab === 1 || !hasChatTab); const [isChatHistoryDrawerOpen, setIsChatHistoryDrawerOpen] = useState(!isMobile && isFullscreenMode); // Fullscreen: URL drives Chat vs Notebooks, but shellViewTab must win when entering // fullscreen from overlay/docked on Notebooks while navigation still lands on /intelligent-assistant. useLayoutEffect(() => { - if (!isFullscreenMode) { + if ( + !isFullscreenMode || + chatPermissionLoading || + notebooksPermissionLoading + ) { return; } if (isNotebooksFullscreenPath) { - setActiveTab(1); - setShellViewTab(1); + if (canShowNotebooks) { + setActiveTab(1); + setShellViewTab(1); + } else if (chatPermissionResolved) { + navigate( + routeConversationId + ? `${LIGHTSPEED_PATH}/conversation/${routeConversationId}` + : LIGHTSPEED_PATH, + { replace: true }, + ); + setActiveTab(0); + setShellViewTab(0); + } return; } const isBaseLightspeedChatRoute = location.pathname === LIGHTSPEED_PATH || location.pathname === `${LIGHTSPEED_PATH}/`; - if (shellViewTab === 1 && isBaseLightspeedChatRoute) { + const isConversationRoute = location.pathname.startsWith( + `${LIGHTSPEED_PATH}/conversation/`, + ); + if ( + !chatPermissionResolved && + canShowNotebooks && + (isBaseLightspeedChatRoute || isConversationRoute) + ) { + navigate( + activeNotebookId + ? `${LIGHTSPEED_PATH}/notebooks/${activeNotebookId}` + : `${LIGHTSPEED_PATH}/notebooks`, + { replace: true }, + ); + setActiveTab(1); + setShellViewTab(1); + return; + } + if (shellViewTab === 1 && isBaseLightspeedChatRoute && canShowNotebooks) { navigate(`${LIGHTSPEED_PATH}/notebooks`, { replace: true }); return; } - setActiveTab(0); - setShellViewTab(0); + if (chatPermissionResolved) { + setActiveTab(0); + setShellViewTab(0); + } }, [ isFullscreenMode, isNotebooksFullscreenPath, @@ -725,6 +770,32 @@ export const LightspeedChat = ({ location.pathname, navigate, setShellViewTab, + chatPermissionLoading, + notebooksPermissionLoading, + chatPermissionResolved, + canShowNotebooks, + activeNotebookId, + routeConversationId, + ]); + + useEffect(() => { + if (chatPermissionLoading || notebooksPermissionLoading) { + return; + } + if (!chatPermissionResolved && canShowNotebooks && activeTab === 0) { + setActiveTab(1); + setShellViewTab(1); + } else if (chatPermissionResolved && !canShowNotebooks && activeTab !== 0) { + setActiveTab(0); + setShellViewTab(0); + } + }, [ + chatPermissionLoading, + notebooksPermissionLoading, + chatPermissionResolved, + canShowNotebooks, + activeTab, + setShellViewTab, ]); // Auto-delete the currently active notebook when the user leaves it, but only @@ -752,13 +823,20 @@ export const LightspeedChat = ({ ]); const handleNotebookTabSelect = (_event: SyntheticEvent, nextTab: number) => { - if (nextTab === 0) { + let logicalTab = 0; + if (hasBothTabs) { + logicalTab = nextTab; + } else if (hasNotebooksTab) { + logicalTab = 1; + } + + if (logicalTab === 0) { maybeAutoDeleteScratchNotebook(); } - setActiveTab(nextTab); - setShellViewTab(nextTab); + setActiveTab(logicalTab); + setShellViewTab(logicalTab); if (isFullscreenMode) { - if (nextTab === 1) { + if (logicalTab === 1) { navigate( activeNotebookId ? `${LIGHTSPEED_PATH}/notebooks/${activeNotebookId}` @@ -772,7 +850,7 @@ export const LightspeedChat = ({ ); } } - if (nextTab === 1 && notebooksPermissionResolved) { + if (logicalTab === 1 && notebooksPermissionResolved) { refetchNotebooks(); } }; @@ -879,6 +957,12 @@ export const LightspeedChat = ({ } }, [displayMode, isMcpSettingsOpen]); + useEffect(() => { + if (!mcpToolsPermissionResolved && isMcpSettingsOpen) { + setIsMcpSettingsOpen(false); + } + }, [mcpToolsPermissionResolved, isMcpSettingsOpen]); + const { isPinningChatsEnabled, pinnedChats, @@ -955,11 +1039,9 @@ export const LightspeedChat = ({ data: conversations = [], isLoading, isRefetching, - } = useConversations(); + } = useConversations(chatPermissionResolved); - const { allowed: hasDeleteAccess } = useLightspeedDeletePermission(); - const { allowed: hasUpdateAccess } = useLightspeedUpdatePermission(); - const samplePrompts = useWelcomePrompts(); + const samplePrompts = useWelcomePrompts(chatPermissionResolved); useEffect(() => { if (!user || !isReady) return; const onOverlayLikeSurface = isFullscreenMode || !routeConversationId; @@ -1206,7 +1288,6 @@ export const LightspeedChat = ({ menuItems: ( <> } onClick={() => openChatRenameModal(conversationSummary.conversation_id) @@ -1236,7 +1317,6 @@ export const LightspeedChat = ({ )} } onClick={() => openDeleteModal(conversationSummary.conversation_id) @@ -1248,15 +1328,7 @@ export const LightspeedChat = ({ ), }; }, - [ - pinnedChats, - hasDeleteAccess, - isPinningChatsEnabled, - hasUpdateAccess, - t, - pinChat, - unpinChat, - ], + [pinnedChats, isPinningChatsEnabled, t, pinChat, unpinChat], ); const notebookConversationIds = useMemo( @@ -1854,7 +1926,7 @@ export const LightspeedChat = ({ ); const mainPanelContent = (() => { - if (!isMcpSettingsOpen) { + if (!isMcpSettingsOpen || !mcpToolsPermissionResolved) { return <>{chatMainContent}; } @@ -2019,16 +2091,17 @@ export const LightspeedChat = ({ isPinningChatsEnabled={isPinningChatsEnabled} hideModelSelector showChatTabOptions={!showNotebooksPanel} + showMcpSettings={mcpToolsPermissionResolved} setDisplayMode={setDisplayModeFromHeader} displayMode={displayMode} onPinnedChatsToggle={handlePinningChatsToggle} onMcpSettingsClick={() => setIsMcpSettingsOpen(true)} /> - {(isFullscreenMode || shouldShowTabs) && } + {shouldShowTabs && ( - - - {t('tabs.notebooks')} - - - } - aria-label={t('tabs.notebooks')} - /> + {hasChatTab && ( + + )} + {hasNotebooksTab && ( + + {t('tabs.notebooks')} + + + } + aria-label={t('tabs.notebooks')} + /> + )} )} - {showChatPanel && ( + {showChatPanel && chatPermissionResolved && ( ( @@ -2175,93 +2252,66 @@ export const LightspeedChat = ({ /> )} - {showNotebooksPanel && - !notebooksPermissionLoading && - hasNotebooksAccess && - activeNotebook && ( - - c.conversation_id === - activeNotebook.metadata?.conversation_id, - )?.topic_summary ?? undefined - } - userName={userName} - avatar={avatar} - profileLoading={profileLoading} - topicRestrictionEnabled={topicRestrictionEnabled} - onClose={handleCloseNotebook} + {showNotebooksPanel && canShowNotebooks && activeNotebook && ( + + c.conversation_id === + activeNotebook.metadata?.conversation_id, + )?.topic_summary ?? undefined + } + userName={userName} + avatar={avatar} + profileLoading={profileLoading} + topicRestrictionEnabled={topicRestrictionEnabled} + onClose={handleCloseNotebook} + isCompact={!isFullscreenMode} + sidebarCollapsed={notebookSidebarCollapsed} + onSidebarCollapsedChange={setNotebookSidebarCollapsed} + isUploadModalOpen={notebookUploadModalOpen} + onUploadModalOpenChange={setNotebookUploadModalOpen} + onUploadsInProgressChange={setNotebookUploadsInProgress} + /> + )} + {showNotebooksPanel && canShowNotebooks && !activeNotebook && ( +
+ - )} - {showNotebooksPanel && - !notebooksPermissionLoading && - hasNotebooksAccess && - !activeNotebook && ( -
{ + maybeAutoDeleteScratchNotebook(); + setActiveNotebookId(notebook.session_id); + if (isFullscreenMode) { + navigate( + `${LIGHTSPEED_PATH}/notebooks/${notebook.session_id}`, + ); + } }} - > - { - maybeAutoDeleteScratchNotebook(); - setActiveNotebookId(notebook.session_id); - if (isFullscreenMode) { - navigate( - `${LIGHTSPEED_PATH}/notebooks/${notebook.session_id}`, - ); - } - }} - onRename={handleRenameNotebook} - onDelete={setDeleteNotebookId} - onCreateNotebook={handleCreateNotebook} - t={t} - /> -
- )} - {showNotebooksPanel && - !notebooksPermissionLoading && - !hasNotebooksAccess && ( - { - setActiveTab(0); - setShellViewTab(0); - }} - > - {t('permission.notebooks.goBack')} - - } + onRename={handleRenameNotebook} + onDelete={setDeleteNotebookId} + onCreateNotebook={handleCreateNotebook} + t={t} /> - )} +
+ )}
diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedChatBoxHeader.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedChatBoxHeader.tsx index a4758749f6c..37f8ec0c11b 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedChatBoxHeader.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedChatBoxHeader.tsx @@ -57,6 +57,8 @@ type LightspeedChatBoxHeaderProps = { hideModelSelector?: boolean; /** When false, omits pinned-chats and MCP entries (Chat tab only). */ showChatTabOptions?: boolean; + /** When false, hides MCP settings from the header menu. */ + showMcpSettings?: boolean; setDisplayMode: (mode: ChatbotDisplayMode) => void; }; @@ -97,6 +99,7 @@ export const LightspeedChatBoxHeader = ({ isModelSelectorDisabled = false, hideModelSelector = false, showChatTabOptions = true, + showMcpSettings = false, setDisplayMode, }: LightspeedChatBoxHeaderProps) => { const [isOptionsMenuOpen, setIsOptionsMenuOpen] = useState(false); @@ -258,17 +261,19 @@ export const LightspeedChatBoxHeader = ({ {t('settings.pinned.enable')}
)} - } - onClick={onMcpSettingsClick} - > - {t('settings.mcp.label')} - - + {showMcpSettings && ( + } + onClick={onMcpSettingsClick} + > + {t('settings.mcp.label')} + + + )} diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedChatContainer.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedChatContainer.tsx index d47e743c5fb..66ec301fb36 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedChatContainer.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedChatContainer.tsx @@ -22,15 +22,13 @@ import { useAsync } from 'react-use'; import { identityApiRef, useApi } from '@backstage/core-plugin-api'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import Button from '@mui/material/Button'; import { useTheme } from '@mui/material/styles'; import { QueryClientProvider } from '@tanstack/react-query'; import { useAllModels } from '../hooks/useAllModels'; -import { useLightspeedViewPermission } from '../hooks/useLightspeedViewPermission'; +import { useIaChatPermission } from '../hooks/useIaChatPermission'; +import { useIaNotebooksPermission } from '../hooks/useIaNotebooksPermission'; import { useTopicRestrictionStatus } from '../hooks/useQuestionValidation'; -import { useTranslation } from '../hooks/useTranslation'; import queryClient from '../utils/queryClient'; import FileAttachmentContextProvider from './AttachmentContext'; import { LightspeedChat } from './LightSpeedChat'; @@ -39,7 +37,6 @@ import { LightspeedChatModelsLoading, ModelsLoadErrorEmptyState, } from './LightspeedChatModelsState'; -import PermissionRequiredState from './PermissionRequiredState'; const THEME_DARK = 'dark'; const THEME_DARK_CLASS = 'pf-v6-theme-dark'; @@ -52,22 +49,25 @@ const LightspeedChatContainerInner = () => { const { palette: { mode }, } = useTheme(); - const { t } = useTranslation(); const identityApi = useApi(identityApiRef); + const { allowed: hasChatAccess, loading: chatPermissionLoading } = + useIaChatPermission(); + + const { allowed: hasNotebooksAccess, loading: notebooksPermissionLoading } = + useIaNotebooksPermission(); + + const permissionsLoading = + chatPermissionLoading || notebooksPermissionLoading; + const hasPluginAccess = hasChatAccess || hasNotebooksAccess; + const { data: models, isLoading: modelsLoading, isError: modelsError, refetch: refetchModels, - } = useAllModels(); - - const { - allowed: hasViewAccess, - loading, - iaChatPermissionName, - } = useLightspeedViewPermission(); + } = useAllModels(hasChatAccess); const { value: profile, loading: profileLoading } = useAsync( async () => await identityApi.getProfileInfo(), @@ -76,7 +76,8 @@ const LightspeedChatContainerInner = () => { const [selectedModel, setSelectedModel] = useState(''); const [selectedProvider, setSelectedProvider] = useState(''); - const { data: topicRestrictionEnabled } = useTopicRestrictionStatus(); + const { data: topicRestrictionEnabled } = + useTopicRestrictionStatus(hasChatAccess); const modelsItems = useMemo( () => @@ -103,33 +104,35 @@ const LightspeedChatContainerInner = () => { // Load last selected model from localStorage useEffect(() => { - if (modelsItems.length > 0) { - try { - const storedData = localStorage.getItem(LAST_SELECTED_MODEL_KEY); - const parsedData = storedData ? JSON.parse(storedData) : null; - - const storedModel = parsedData?.model - ? modelsItems.find(m => m.value === parsedData.model) - : null; - - if (storedModel) { - setSelectedModel(storedModel.value); - setSelectedProvider(storedModel.provider); - } else { - setSelectedModel(modelsItems[0].value); - setSelectedProvider(modelsItems[0].provider); - } - } catch (error) { - // eslint-disable-next-line no-console - console.error( - 'Error loading last selected model from localStorage:', - error, - ); + if (!hasChatAccess || modelsItems.length === 0) { + return; + } + + try { + const storedData = localStorage.getItem(LAST_SELECTED_MODEL_KEY); + const parsedData = storedData ? JSON.parse(storedData) : null; + + const storedModel = parsedData?.model + ? modelsItems.find(m => m.value === parsedData.model) + : null; + + if (storedModel) { + setSelectedModel(storedModel.value); + setSelectedProvider(storedModel.provider); + } else { setSelectedModel(modelsItems[0].value); setSelectedProvider(modelsItems[0].provider); } + } catch (error) { + // eslint-disable-next-line no-console + console.error( + 'Error loading last selected model from localStorage:', + error, + ); + setSelectedModel(modelsItems[0].value); + setSelectedProvider(modelsItems[0].provider); } - }, [modelsItems]); + }, [hasChatAccess, modelsItems]); // Save selected model to localStorage useEffect(() => { @@ -152,52 +155,41 @@ const LightspeedChatContainerInner = () => { } }, [selectedModel, selectedProvider]); - if (loading) { + if (permissionsLoading) { // Never return null inside the overlay modal: PatternFly's focus-trap requires at least // one tabbable node (e.g. after removing the modal close button). Locale switches can // briefly re-enter this loading state. return ; } - if (!hasViewAccess) { - return ( - - {t('common.readMore')}   - - } - /> - ); + if (!hasPluginAccess) { + return null; } - if (modelsLoading) { + if (hasChatAccess && modelsLoading) { return ; } // TanStack Query can keep the last successful `data` while `isError` is true after a // failed refetch. Prefer showing chat when we still have LLM rows; only use the full-page // error state when there is nothing usable to render. - if (modelsError && modelsItems.length === 0) { + if (hasChatAccess && modelsError && modelsItems.length === 0) { return refetchModels()} />; } - if (modelsItems.length === 0) { + if (hasChatAccess && modelsItems.length === 0) { return ; } + const resolvedSelectedModel = selectedModel || modelsItems[0]?.value || ''; + const resolvedSelectedProvider = + selectedProvider || modelsItems[0]?.provider || ''; + return ( { setSelectedModel(item); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedFABContent.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedFABContent.tsx index bf6d5178c4b..3fecebb95ee 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedFABContent.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedFABContent.tsx @@ -23,6 +23,8 @@ import Tooltip from '@mui/material/Tooltip'; import { ChatbotDisplayMode } from '@patternfly/chatbot'; import { DOCKED_CONTENT_OFFSET, LIGHTSPEED_FAB_ELEMENT_ID } from '../const'; +import { useIaChatPermission } from '../hooks/useIaChatPermission'; +import { useIaNotebooksPermission } from '../hooks/useIaNotebooksPermission'; import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext'; import { useTranslation } from '../hooks/useTranslation'; import { @@ -37,6 +39,14 @@ export const LightspeedFABContent = () => { const theme = useTheme(); const { isChatbotActive, toggleChatbot, displayMode } = useLightspeedDrawerContext(); + const { allowed: hasChatAccess, loading: chatPermissionLoading } = + useIaChatPermission(); + const { allowed: hasNotebooksAccess, loading: notebooksPermissionLoading } = + useIaNotebooksPermission(); + + const permissionsLoading = + chatPermissionLoading || notebooksPermissionLoading; + const hasPluginAccess = hasChatAccess || hasNotebooksAccess; const fabRef = useRef(null); const fabEdgeInset = getLightspeedFabEdgeInset(theme); @@ -77,6 +87,10 @@ export const LightspeedFABContent = () => { return null; } + if (permissionsLoading || !hasPluginAccess) { + return null; + } + return ( { const { t } = useTranslation(); + const { allowed: hasChatAccess, loading: chatPermissionLoading } = + useIaChatPermission(); + const { allowed: hasNotebooksAccess, loading: notebooksPermissionLoading } = + useIaNotebooksPermission(); + + const permissionsLoading = + chatPermissionLoading || notebooksPermissionLoading; + const hasPluginAccess = hasChatAccess || hasNotebooksAccess; + + if (!permissionsLoading && !hasPluginAccess) { + return ; + } return ( diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/McpConfigureServerModal.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/McpConfigureServerModal.tsx index 5b48aea0ab6..f1d190baa2e 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/McpConfigureServerModal.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/McpConfigureServerModal.tsx @@ -130,7 +130,6 @@ export const McpConfigureServerModal = ({ close, save, removePersonalToken, - canManageMcp, configureModalTitle, isConfigureModalSaving, isSaveTokenButtonDisabled, @@ -447,7 +446,6 @@ export const McpConfigureServerModal = ({ variant="primary" onClick={() => void save()} isDisabled={ - !canManageMcp || isConfigureModalSaving || tokenValidationState === 'validating' || isSaveTokenButtonDisabled || @@ -462,7 +460,6 @@ export const McpConfigureServerModal = ({ isDanger onClick={() => void removePersonalToken()} isDisabled={ - !canManageMcp || isConfigureModalSaving || tokenValidationState === 'validating' || isUpdatingModalStatus || diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/McpServersSettings.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/McpServersSettings.tsx index 7ea4f8ec9e7..fead09126f5 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/McpServersSettings.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/McpServersSettings.tsx @@ -17,7 +17,6 @@ import { useCallback, useEffect, useState } from 'react'; import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; -import { usePermission } from '@backstage/plugin-permission-react'; import GlobalStyles from '@mui/material/GlobalStyles'; import { styled } from '@mui/material/styles'; @@ -35,8 +34,7 @@ import { } from '@patternfly/react-icons'; import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; -import { iaMcpToolsPermission } from '@red-hat-developer-hub/backstage-plugin-intelligent-assistant-common'; - +import { useIaMcpToolsPermission } from '../hooks/useIaMcpToolsPermission'; import { useMcpConfigureModal } from '../hooks/useMcpConfigureModal'; import { useTranslation } from '../hooks/useTranslation'; import { McpConfigureServerModal } from './McpConfigureServerModal'; @@ -308,10 +306,9 @@ export const McpServersSettings = ({ const { t } = useTranslation(); const configApi = useApi(configApiRef); const fetchApi = useApi(fetchApiRef); - const mcpToolsPermission = usePermission({ - permission: iaMcpToolsPermission, - }); - const canManageMcp = mcpToolsPermission.allowed; + const { allowed: hasMcpToolsAccess, loading: mcpToolsPermissionLoading } = + useIaMcpToolsPermission(); + const [servers, setServers] = useState([]); const [sortColumn, setSortColumn] = useState('name'); const [sortAsc, setSortAsc] = useState(true); @@ -415,24 +412,22 @@ export const McpServersSettings = ({ const uiServers = (data.servers ?? []).map(server => toUiServer(server)); setServers(uiServers); - if (canManageMcp) { - const serversToValidate = uiServers.filter(server => server.hasToken); - void Promise.allSettled( - serversToValidate.map(async server => { - try { - await validateServer(server.name); - } catch (validationError) { - setError( - prev => - prev ?? - (validationError instanceof Error - ? validationError.message - : `Failed to validate ${server.name}`), - ); - } - }), - ); - } + const serversToValidate = uiServers.filter(server => server.hasToken); + void Promise.allSettled( + serversToValidate.map(async server => { + try { + await validateServer(server.name); + } catch (validationError) { + setError( + prev => + prev ?? + (validationError instanceof Error + ? validationError.message + : `Failed to validate ${server.name}`), + ); + } + }), + ); } catch (e) { setError( e instanceof Error ? e.message : 'Failed to load MCP server settings', @@ -440,20 +435,20 @@ export const McpServersSettings = ({ } finally { setIsLoading(false); } - }, [canManageMcp, fetchJson, getBaseUrl, validateServer]); + }, [fetchJson, getBaseUrl, validateServer]); useEffect(() => { + if (mcpToolsPermissionLoading || !hasMcpToolsAccess) { + return; + } loadServers(); - }, [loadServers]); + }, [loadServers, mcpToolsPermissionLoading, hasMcpToolsAccess]); const patchServer = useCallback( async ( serverName: string, body: { enabled?: boolean; token?: string | null }, ) => { - if (!canManageMcp) { - return; - } setError(null); setIsSaving(prev => ({ ...prev, [serverName]: true })); try { @@ -488,12 +483,11 @@ export const McpServersSettings = ({ setIsSaving(prev => ({ ...prev, [serverName]: false })); } }, - [canManageMcp, fetchJson, getBaseUrl, loadServers], + [fetchJson, getBaseUrl, loadServers], ); const configureModal = useMcpConfigureModal({ servers, - canManageMcp, isSaving, patchServer, validateServer, @@ -539,6 +533,10 @@ export const McpServersSettings = ({ ); }; + if (mcpToolsPermissionLoading || !hasMcpToolsAccess) { + return null; + } + return ( )} - {!mcpToolsPermission.loading && !canManageMcp && ( - - )} - } variant="plain" className={mcpClasses.actionButton} - isDisabled={!canManageMcp} onClick={() => configureModal.open(server)} /> diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/PermissionRequiredIcon.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/PermissionRequiredIcon.tsx deleted file mode 100644 index d0771f887d2..00000000000 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/PermissionRequiredIcon.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useTranslation } from '../hooks/useTranslation'; -import permissionRequired from '../images/permission-required.svg'; - -export const PermissionRequiredIcon = () => { - const { t } = useTranslation(); - - return ( - {t('icon.permissionRequired.alt')} - ); -}; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/PermissionRequiredState.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/PermissionRequiredState.tsx deleted file mode 100644 index 60abf587956..00000000000 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/PermissionRequiredState.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Fragment } from 'react'; - -import { EmptyState } from '@backstage/core-components'; - -import Box from '@mui/material/Box'; - -import { useTranslation } from '../hooks/useTranslation'; -import { PermissionRequiredIcon } from './PermissionRequiredIcon'; -import { Trans } from './Trans'; - -interface PermissionRequiredStateProps { - subject: string; - permissions: string[]; - action: JSX.Element; -} - -const PermissionRequiredState = ({ - subject, - permissions, - action, -}: PermissionRequiredStateProps) => { - const { t } = useTranslation(); - - const permissionsList = ( - <> - {permissions.map((perm, i) => ( - - {perm} - {i < permissions.length - 1 && ' and '} - - ))} - - ); - - return ( - ({ - display: 'flex', - flexDirection: 'column', - width: '100%', - height: '100%', - minHeight: '100%', - flex: 1, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: theme.palette.background.default, - containerType: 'inline-size', - '& [class*="BackstageEmptyState-root"]': { - alignItems: 'center', - padding: theme.spacing(4), - }, - '& [class*="MuiTypography-h5"]': { - fontSize: 'clamp(1.875rem, 3.75cqi, 3.125rem)', - fontWeight: 400, - }, - '& [class*="MuiTypography-body1"]': { - fontSize: '1em', - color: theme.palette.text.secondary, - '& b': { - fontWeight: 500, - color: theme.palette.text.primary, - }, - }, - '@container (max-width: 899px)': { - '& [class*="BackstageEmptyState-root"]': { - textAlign: 'center', - }, - '& [class*="MuiGrid-grid-md-6"]': { - maxWidth: '100%', - flexBasis: '100%', - }, - '& [class*="BackstageEmptyState-imageContainer"]': { - order: -1, - display: 'flex', - justifyContent: 'center', - marginBottom: theme.spacing(-4), - }, - }, - })} - > - ': <>{subject}, - '': permissionsList, - }} - /> - } - missing={{ customImage: }} - action={action} - /> - - ); -}; -export default PermissionRequiredState; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedChat.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedChat.test.tsx index 19e882d785a..3bc492064a0 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedChat.test.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedChat.test.tsx @@ -940,7 +940,7 @@ describe('LightspeedChat', () => { }); }); - it('should show permission required state when notebooks permission is denied', async () => { + it('should hide tabs and show header divider when notebooks permission is denied', async () => { render(setupLightspeedChat()); await waitFor(() => { @@ -949,19 +949,25 @@ describe('LightspeedChat', () => { ).toBeInTheDocument(); }); - const notebooksTab = screen.getByRole('tab', { name: 'Notebooks' }); - await userEvent.click(notebooksTab); + expect(screen.queryByRole('tab')).not.toBeInTheDocument(); + expect( + screen.getByTestId('lightspeed-header-divider'), + ).toBeInTheDocument(); + }); + }); - await waitFor(() => { - expect(screen.getByText('Missing permissions')).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Go back' }), - ).toBeInTheDocument(); + describe('chat permission denied', () => { + beforeEach(() => { + mockUsePermission.mockImplementation((args: any) => { + if (args.permission.name === 'intelligent-assistant.chat') { + return { loading: false, allowed: false }; + } + return { loading: false, allowed: true }; }); }); - it('should navigate back to chat tab when Go back is clicked', async () => { - render(setupLightspeedChat()); + it('should hide tabs and show header divider when chat permission is denied', async () => { + render(setupLightspeedChat('/intelligent-assistant/notebooks')); await waitFor(() => { expect( @@ -969,21 +975,10 @@ describe('LightspeedChat', () => { ).toBeInTheDocument(); }); - const notebooksTab = screen.getByRole('tab', { name: 'Notebooks' }); - await userEvent.click(notebooksTab); - - await waitFor(() => { - expect(screen.getByText('Missing permissions')).toBeInTheDocument(); - }); - - const goBackButton = screen.getByRole('button', { name: 'Go back' }); - await userEvent.click(goBackButton); - - await waitFor(() => { - expect( - screen.queryByText('Missing permissions'), - ).not.toBeInTheDocument(); - }); + expect(screen.queryByRole('tab')).not.toBeInTheDocument(); + expect( + screen.getByTestId('lightspeed-header-divider'), + ).toBeInTheDocument(); }); }); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedFAB.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedFAB.test.tsx index 3027100a9b4..5f42a38ce59 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedFAB.test.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedFAB.test.tsx @@ -17,6 +17,8 @@ import { ChatbotDisplayMode } from '@patternfly/chatbot'; import { fireEvent, render, screen } from '@testing-library/react'; +import { useIaChatPermission } from '../../hooks/useIaChatPermission'; +import { useIaNotebooksPermission } from '../../hooks/useIaNotebooksPermission'; import { mockUseTranslation } from '../../test-utils/mockTranslations'; import { LightspeedDrawerContext } from '../LightspeedDrawerContext'; import { LightspeedFAB } from '../LightspeedFAB'; @@ -25,8 +27,23 @@ jest.mock('../../hooks/useTranslation', () => ({ useTranslation: jest.fn(() => mockUseTranslation()), })); +jest.mock('../../hooks/useIaChatPermission', () => ({ + useIaChatPermission: jest.fn(), +})); + +jest.mock('../../hooks/useIaNotebooksPermission', () => ({ + useIaNotebooksPermission: jest.fn(), +})); + describe('LightspeedFAB', () => { const mockToggleChatbot = jest.fn(); + const mockUseIaChatPermission = useIaChatPermission as jest.MockedFunction< + typeof useIaChatPermission + >; + const mockUseIaNotebooksPermission = + useIaNotebooksPermission as jest.MockedFunction< + typeof useIaNotebooksPermission + >; const createContextValue = (overrides = {}) => ({ isChatbotActive: false, @@ -60,6 +77,14 @@ describe('LightspeedFAB', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseIaChatPermission.mockReturnValue({ + allowed: true, + loading: false, + }); + mockUseIaNotebooksPermission.mockReturnValue({ + allowed: true, + loading: false, + }); }); it('should render FAB button when displayMode is overlay', () => { @@ -98,6 +123,59 @@ describe('LightspeedFAB', () => { expect(screen.queryByTestId('lightspeed-fab')).not.toBeInTheDocument(); }); + it('should not render FAB when user lacks chat and notebooks permissions', () => { + mockUseIaChatPermission.mockReturnValue({ + allowed: false, + loading: false, + }); + mockUseIaNotebooksPermission.mockReturnValue({ + allowed: false, + loading: false, + }); + + renderWithContext( + createContextValue({ + displayMode: ChatbotDisplayMode.default, + }), + ); + + expect(screen.queryByTestId('lightspeed-fab')).not.toBeInTheDocument(); + }); + + it('should render FAB when user has only notebooks permission', () => { + mockUseIaChatPermission.mockReturnValue({ + allowed: false, + loading: false, + }); + mockUseIaNotebooksPermission.mockReturnValue({ + allowed: true, + loading: false, + }); + + renderWithContext( + createContextValue({ + displayMode: ChatbotDisplayMode.default, + }), + ); + + expect(screen.getByTestId('lightspeed-fab')).toBeInTheDocument(); + }); + + it('should not render FAB while permissions are loading', () => { + mockUseIaChatPermission.mockReturnValue({ + allowed: false, + loading: true, + }); + + renderWithContext( + createContextValue({ + displayMode: ChatbotDisplayMode.default, + }), + ); + + expect(screen.queryByTestId('lightspeed-fab')).not.toBeInTheDocument(); + }); + it('should call toggleChatbot when FAB button is clicked', () => { renderWithContext( createContextValue({ diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedPage.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedPage.test.tsx index 64d1855fca1..108c45533a7 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedPage.test.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedPage.test.tsx @@ -73,6 +73,15 @@ const mockUsePermission = usePermission as jest.MockedFunction< describe('LightspeedPage', () => { beforeEach(() => { localStorage.clear(); + mockUsePermission.mockImplementation(({ permission }) => { + if (permission.name === 'intelligent-assistant.chat') { + return { loading: false, allowed: true }; + } + if (permission.name === 'intelligent-assistant.notebooks') { + return { loading: false, allowed: true }; + } + return { loading: false, allowed: false }; + }); const { useAllModels } = require('../../hooks/useAllModels'); (useAllModels as jest.Mock).mockReturnValue({ data: [ @@ -107,8 +116,11 @@ describe('LightspeedPage', () => { }); }); - it('should display missing permissions alert', async () => { - mockUsePermission.mockReturnValue({ loading: false, allowed: false }); + it('should show 404 page when no feature permissions are granted', async () => { + mockUsePermission.mockImplementation(() => ({ + loading: false, + allowed: false, + })); await renderInTestApp( { ); await waitFor(() => { - expect(screen.getByText('Missing permissions')).toBeInTheDocument(); + expect(screen.queryByText('LightspeedChat')).not.toBeInTheDocument(); + expect(screen.getByTestId('error')).toHaveTextContent( + 'ERROR 404: Page not found', + ); }); }); it('should display lightspeed chatbot', async () => { - mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + mockUsePermission.mockImplementation(({ permission }) => { + if (permission.name === 'intelligent-assistant.chat') { + return { loading: false, allowed: true }; + } + if (permission.name === 'intelligent-assistant.notebooks') { + return { loading: false, allowed: true }; + } + return { loading: false, allowed: false }; + }); await renderInTestApp( { }); }); - it('should translate permission messages correctly', () => { - const { result } = renderHook(() => useTranslation()); - - expect(result.current.t('permission.required.title')).toBe( - 'Missing permissions', - ); - expect(result.current.t('permission.required.description')).toBe( - 'To view , contact your administrator to give the permission.', - ); - }); - it('should translate conversation messages correctly', () => { const { result } = renderHook(() => useTranslation()); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/iaRbacPermissionGating.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/iaRbacPermissionGating.test.tsx new file mode 100644 index 00000000000..972721d6fe3 --- /dev/null +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/iaRbacPermissionGating.test.tsx @@ -0,0 +1,554 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MemoryRouter } from 'react-router-dom'; + +import { + configApiRef, + IdentityApi, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { usePermission } from '@backstage/plugin-permission-react'; +import { mockApis, TestApiProvider } from '@backstage/test-utils'; + +import { ChatbotDisplayMode } from '@patternfly/chatbot'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { lightspeedApiRef } from '../../api/api'; +import { notebooksApiRef } from '../../api/notebooksApi'; +import { useConversations, useNotebookSessions } from '../../hooks'; +import { useAllModels } from '../../hooks/useAllModels'; +import { useIaChatPermission } from '../../hooks/useIaChatPermission'; +import { useIaNotebooksPermission } from '../../hooks/useIaNotebooksPermission'; +import { useLightspeedDrawerContext } from '../../hooks/useLightspeedDrawerContext'; +import { mockUseTranslation } from '../../test-utils/mockTranslations'; +import FileAttachmentContextProvider from '../AttachmentContext'; +import { LightspeedChat } from '../LightSpeedChat'; +import { LightspeedChatContainer } from '../LightspeedChatContainer'; +import { LightspeedFAB } from '../LightspeedFAB'; +import { NotebookStreamProvider } from '../notebooks/NotebookStreamProvider'; + +type IaPermissionMatrix = { + chat: boolean; + notebooks: boolean; + mcp: boolean; +}; + +const SCENARIOS: Record = { + '1-both-chat-and-notebooks': { chat: true, notebooks: true, mcp: false }, + '2-chat-only': { chat: true, notebooks: false, mcp: false }, + '3-notebooks-only': { chat: false, notebooks: true, mcp: false }, + '4-no-permission': { chat: false, notebooks: false, mcp: false }, + '5-mcp-settings': { chat: true, notebooks: true, mcp: true }, + '6-mcp-denied': { chat: true, notebooks: true, mcp: false }, +}; + +const PERMISSION_NAMES = { + chat: 'intelligent-assistant.chat', + notebooks: 'intelligent-assistant.notebooks', + mcp: 'intelligent-assistant.mcp.tools', +} as const; + +const identityApi = { + async getCredentials() { + return { token: 'test-token' }; + }, + getBackstageIdentity: jest + .fn() + .mockReturnValue({ userEntityRef: 'user:test' }), + getProfileInfo: jest.fn().mockResolvedValue({ displayName: 'Test User' }), +} as unknown as IdentityApi; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + experimental_prefetchInRender: true, + }, + }, +}); + +jest.mock('@backstage/plugin-permission-react', () => ({ + usePermission: jest.fn(), + RequirePermission: jest.fn(), +})); + +jest.mock('../../hooks/useIaChatPermission', () => ({ + useIaChatPermission: jest.fn(), +})); + +jest.mock('../../hooks/useIaNotebooksPermission', () => ({ + useIaNotebooksPermission: jest.fn(), +})); + +jest.mock('../../hooks/useAllModels', () => ({ + useAllModels: jest.fn(), +})); + +jest.mock('../../hooks/useQuestionValidation', () => ({ + useTopicRestrictionStatus: jest.fn().mockReturnValue({ data: false }), +})); + +jest.mock('../../hooks/useConversations', () => ({ + useConversations: jest.fn().mockReturnValue({ + data: [], + isRefetching: false, + isLoading: false, + }), +})); + +jest.mock('../../hooks/notebooks/useNotebookSessions', () => ({ + useNotebookSessions: jest.fn().mockReturnValue({ + data: [], + refetch: jest.fn(), + }), +})); + +jest.mock('../../hooks/notebooks/useNotebookSession', () => ({ + useNotebookSession: jest.fn().mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + }), +})); + +jest.mock('../../hooks/useFeedbackActions', () => ({ + useFeedbackActions: jest.fn().mockReturnValue([]), +})); + +jest.mock('../../hooks/useDeleteConversation', () => ({ + useDeleteConversation: jest.fn().mockResolvedValue({ data: [] }), +})); + +jest.mock('../../hooks/useConversationMessages', () => ({ + useConversationMessages: jest.fn().mockReturnValue({ + conversationMessages: [], + }), +})); + +jest.mock('../../hooks/useTranslation', () => ({ + useTranslation: jest.fn(() => mockUseTranslation()), +})); + +jest.mock('../../hooks/useLightspeedDrawerContext', () => ({ + useLightspeedDrawerContext: jest.fn(), +})); + +jest.mock('../../hooks/usePinnedChatsSettings', () => ({ + usePinnedChatsSettings: jest.fn().mockReturnValue({ + isPinningChatsEnabled: true, + pinnedChats: [], + handlePinningChatsToggle: jest.fn(), + pinChat: jest.fn(), + unpinChat: jest.fn(), + }), +})); + +jest.mock('../../hooks/useSortSettings', () => ({ + useSortSettings: jest.fn().mockReturnValue({ + selectedSort: 'newest', + handleSortChange: jest.fn(), + }), +})); + +jest.mock('@patternfly/chatbot', () => { + const actual = jest.requireActual('@patternfly/chatbot'); + return { + ...actual, + MessageBox: () => <>MessageBox, + }; +}); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => jest.fn(), +})); + +const mockUsePermission = usePermission as jest.MockedFunction< + typeof usePermission +>; +const mockUseIaChatPermission = useIaChatPermission as jest.MockedFunction< + typeof useIaChatPermission +>; +const mockUseIaNotebooksPermission = + useIaNotebooksPermission as jest.MockedFunction< + typeof useIaNotebooksPermission + >; +const mockUseAllModels = useAllModels as jest.Mock; +const mockUseConversations = useConversations as jest.Mock; +const mockUseNotebookSessions = useNotebookSessions as jest.Mock; +const mockUseLightspeedDrawerContext = + useLightspeedDrawerContext as jest.MockedFunction< + typeof useLightspeedDrawerContext + >; + +const configApi = mockApis.config({ + data: { + 'intelligent-assistant': { + notebooks: { + enabled: true, + queryDefaults: { + model: 'gpt-4', + provider_id: 'openai', + }, + }, + }, + }, +}); + +const mockLightspeedApi = { + getAllModels: jest.fn().mockResolvedValue([]), + getConversationMessages: jest.fn().mockResolvedValue([]), + createMessage: jest.fn().mockResolvedValue(new Response().body), + deleteConversation: jest.fn().mockResolvedValue({ success: true }), + renameConversation: jest.fn().mockResolvedValue({ success: true }), + getConversations: jest.fn().mockResolvedValue([]), + getNotebookConversationIds: jest.fn().mockResolvedValue([]), + getFeedbackStatus: jest.fn().mockResolvedValue(false), + captureFeedback: jest.fn().mockResolvedValue({ response: 'success' }), + isTopicRestrictionEnabled: jest.fn().mockResolvedValue(false), + stopMessage: jest.fn().mockResolvedValue({ success: true }), +}; + +const mockNotebooksApi = { + createSession: jest.fn().mockResolvedValue({}), + listSessions: jest.fn().mockResolvedValue([]), + renameSession: jest.fn().mockResolvedValue(undefined), + deleteSession: jest.fn().mockResolvedValue(undefined), + uploadDocument: jest.fn().mockResolvedValue({}), + listDocuments: jest.fn().mockResolvedValue([]), + deleteDocument: jest.fn().mockResolvedValue(undefined), + getDocumentStatus: jest.fn().mockResolvedValue({}), + querySession: jest.fn().mockResolvedValue({ + read: jest.fn().mockResolvedValue({ done: true, value: undefined }), + }), +}; + +function mockPermissions(matrix: IaPermissionMatrix) { + mockUsePermission.mockImplementation(({ permission }: any) => { + const name = permission.name as string; + if (name === PERMISSION_NAMES.chat) { + return { loading: false, allowed: matrix.chat }; + } + if (name === PERMISSION_NAMES.notebooks) { + return { loading: false, allowed: matrix.notebooks }; + } + if (name === PERMISSION_NAMES.mcp) { + return { loading: false, allowed: matrix.mcp }; + } + return { loading: false, allowed: false }; + }); + + mockUseIaChatPermission.mockReturnValue({ + loading: false, + allowed: matrix.chat, + }); + mockUseIaNotebooksPermission.mockReturnValue({ + loading: false, + allowed: matrix.notebooks, + }); +} + +const fabContextValue = { + isChatbotActive: false, + toggleChatbot: jest.fn(), + displayMode: ChatbotDisplayMode.default, + setDisplayMode: jest.fn(), + drawerWidth: 500, + setDrawerWidth: jest.fn(), + currentConversationId: undefined, + setCurrentConversationId: jest.fn(), + draftMessage: '', + setDraftMessage: jest.fn(), + draftFileContents: [], + setDraftFileContents: jest.fn(), + shellViewTab: 0, + setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), +}; + +const chatDrawerContextValue = { + isChatbotActive: false, + toggleChatbot: jest.fn(), + displayMode: ChatbotDisplayMode.embedded, + setDisplayMode: jest.fn(), + drawerWidth: 500, + setDrawerWidth: jest.fn(), + currentConversationId: undefined, + setCurrentConversationId: jest.fn(), + draftMessage: '', + setDraftMessage: jest.fn(), + draftFileContents: [], + setDraftFileContents: jest.fn(), + consumePendingOverlayThreadHandoff: jest.fn(() => false), + shellViewTab: 0, + setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), +}; + +const setupLightspeedChat = (initialPath = '/intelligent-assistant') => ( + + + + + + {}} + topicRestrictionEnabled={false} + selectedProvider="openai" + models={[]} + avatar="test" + userName="user:test" + /> + + + + + +); + +const setupLightspeedChatContainer = ( + initialPath = '/intelligent-assistant', +) => ( + + + + + +); + +describe('IA RBAC permission gating scenarios', () => { + beforeEach(() => { + jest.clearAllMocks(); + queryClient.clear(); + + mockUseAllModels.mockReturnValue({ + data: [ + { + provider_resource_id: 'model-1', + provider_id: 'provider-1', + model_type: 'llm', + }, + ], + isLoading: false, + isError: false, + refetch: jest.fn(), + }); + + mockUseConversations.mockReturnValue({ + data: [], + isRefetching: false, + isLoading: false, + }); + + mockUseNotebookSessions.mockReturnValue({ + data: [], + refetch: jest.fn(), + }); + + mockUseLightspeedDrawerContext.mockReturnValue(chatDrawerContextValue); + }); + + describe('Scenario 1: both chat and notebooks', () => { + beforeEach(() => { + mockPermissions(SCENARIOS['1-both-chat-and-notebooks']); + }); + + it('shows the FAB', () => { + mockUseLightspeedDrawerContext.mockReturnValue(fabContextValue); + render(); + + expect(screen.getByTestId('lightspeed-fab')).toBeInTheDocument(); + }); + + it('shows Chat and Notebooks tabs', async () => { + render(setupLightspeedChat()); + + await waitFor(() => { + expect(screen.getByRole('tab', { name: 'Chat' })).toBeInTheDocument(); + expect( + screen.getByRole('tab', { name: 'Notebooks' }), + ).toBeInTheDocument(); + }); + }); + + it('enables chat and notebooks data hooks', async () => { + render(setupLightspeedChat()); + + await waitFor(() => { + expect(mockUseConversations).toHaveBeenCalledWith(true); + expect(mockUseNotebookSessions).toHaveBeenCalledWith(true); + }); + }); + }); + + describe('Scenario 2: chat only', () => { + beforeEach(() => { + mockPermissions(SCENARIOS['2-chat-only']); + }); + + it('shows the FAB', () => { + mockUseLightspeedDrawerContext.mockReturnValue(fabContextValue); + render(); + + expect(screen.getByTestId('lightspeed-fab')).toBeInTheDocument(); + }); + + it('hides tabs and shows chat without notebooks', async () => { + render(setupLightspeedChat()); + + await waitFor(() => { + expect( + screen.getByText('Developer Hub Intelligent Assistant'), + ).toBeInTheDocument(); + }); + + expect(screen.queryByRole('tab')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'New chat' }), + ).toBeInTheDocument(); + expect( + screen.queryByText('No created notebooks'), + ).not.toBeInTheDocument(); + }); + + it('enables chat hooks and disables notebooks hooks', async () => { + render(setupLightspeedChat()); + + await waitFor(() => { + expect(mockUseConversations).toHaveBeenCalledWith(true); + expect(mockUseNotebookSessions).toHaveBeenCalledWith(false); + }); + }); + }); + + describe('Scenario 3: notebooks only', () => { + beforeEach(() => { + mockPermissions(SCENARIOS['3-notebooks-only']); + }); + + it('shows the FAB', () => { + mockUseLightspeedDrawerContext.mockReturnValue(fabContextValue); + render(); + + expect(screen.getByTestId('lightspeed-fab')).toBeInTheDocument(); + }); + + it('hides tabs and shows notebooks without chat controls', async () => { + render(setupLightspeedChat('/intelligent-assistant/notebooks')); + + await waitFor(() => { + expect( + screen.getByText('Developer Hub Intelligent Assistant'), + ).toBeInTheDocument(); + }); + + expect(screen.queryByRole('tab')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'New chat' }), + ).not.toBeInTheDocument(); + expect(screen.getByText('No created notebooks')).toBeInTheDocument(); + }); + + it('disables chat hooks and enables notebooks hooks', async () => { + render(setupLightspeedChat('/intelligent-assistant/notebooks')); + + await waitFor(() => { + expect(mockUseConversations).toHaveBeenCalledWith(false); + expect(mockUseNotebookSessions).toHaveBeenCalledWith(true); + }); + }); + }); + + describe('Scenario 4: no permission', () => { + beforeEach(() => { + mockPermissions(SCENARIOS['4-no-permission']); + }); + + it('hides the FAB', () => { + mockUseLightspeedDrawerContext.mockReturnValue(fabContextValue); + render(); + + expect(screen.queryByTestId('lightspeed-fab')).not.toBeInTheDocument(); + }); + + it('renders nothing from the chat container', async () => { + const { container } = render(setupLightspeedChatContainer()); + + await waitFor(() => { + expect(container).toBeEmptyDOMElement(); + }); + }); + }); + + describe('Scenario 5: MCP settings allowed', () => { + beforeEach(() => { + mockPermissions(SCENARIOS['5-mcp-settings']); + }); + + it('shows MCP settings in the header menu', async () => { + render(setupLightspeedChat()); + + await waitFor(() => { + expect( + screen.getByText('Developer Hub Intelligent Assistant'), + ).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByLabelText('Options')); + expect(screen.getByText('MCP settings')).toBeInTheDocument(); + }); + }); + + describe('Scenario 6: MCP denied', () => { + beforeEach(() => { + mockPermissions(SCENARIOS['6-mcp-denied']); + }); + + it('hides MCP settings from the header menu', async () => { + render(setupLightspeedChat()); + + await waitFor(() => { + expect( + screen.getByText('Developer Hub Intelligent Assistant'), + ).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByLabelText('Options')); + expect(screen.queryByText('MCP settings')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/__tests__/useAllModels.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/__tests__/useAllModels.test.tsx new file mode 100644 index 00000000000..4bcdf7f63e9 --- /dev/null +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/__tests__/useAllModels.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useApi } from '@backstage/core-plugin-api'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { useAllModels } from '../useAllModels'; + +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApi: jest.fn(), +})); + +const mockGetAllModels = jest.fn(); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, +}); + +const wrapper = ({ children }: { children?: React.ReactNode }): any => ( + {children} +); + +describe('useAllModels', () => { + beforeEach(() => { + jest.clearAllMocks(); + queryClient.clear(); + (useApi as jest.Mock).mockReturnValue({ + getAllModels: mockGetAllModels, + }); + }); + + it('fetches models when enabled', async () => { + mockGetAllModels.mockResolvedValue([]); + + const { result } = renderHook(() => useAllModels(true), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockGetAllModels).toHaveBeenCalledTimes(1); + }); + + it('does not fetch models when disabled', async () => { + renderHook(() => useAllModels(false), { wrapper }); + + await waitFor(() => { + expect(mockGetAllModels).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/__tests__/useConversations.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/__tests__/useConversations.test.tsx index 5e31ebc4332..6c39d93a6b4 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/__tests__/useConversations.test.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/__tests__/useConversations.test.tsx @@ -120,6 +120,18 @@ describe('useConversations', () => { jest.useRealTimers(); }); + it('should not fetch conversations when disabled', async () => { + (useApi as jest.Mock).mockReturnValue({ + getConversations: mockGetConversations, + }); + + renderHook(() => useConversations(false), { wrapper }); + + await waitFor(() => { + expect(mockGetConversations).not.toHaveBeenCalled(); + }); + }); + it('should not refetch when all topic_summary are set', async () => { const mockData = [ { diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/index.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/index.ts index dc22d0a9ce5..2805dcb5ce1 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/index.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/index.ts @@ -22,9 +22,10 @@ export * from './useDeleteConversation'; export * from './notebooks/useDeleteNotebook'; export * from './useIsMobile'; export * from './useLastOpenedConversation'; -export * from './useLightspeedDeletePermission'; -export * from './notebooks/useLightspeedNotebooksPermission'; -export * from './useLightspeedViewPermission'; +export * from './useIaChatPermission'; +export * from './useIaMcpToolsPermission'; +export * from './useIaNotebooksPermission'; +export * from './useIaSkillsPermission'; export * from './useMcpConfigureModal'; export * from './useNotebookConversationIds'; export * from './useDisplayModeSettings'; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useAllModels.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useAllModels.ts index df2545e6de7..454ce5af3f8 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useAllModels.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useAllModels.ts @@ -22,7 +22,9 @@ import { lightspeedApiRef } from '../api/api'; import { LCSModel } from '../types'; // Fetch all models -export const useAllModels = (): UseQueryResult => { +export const useAllModels = ( + enabled = true, +): UseQueryResult => { const lightspeedApi = useApi(lightspeedApiRef); return useQuery({ queryKey: ['models'], @@ -30,6 +32,7 @@ export const useAllModels = (): UseQueryResult => { const response = await lightspeedApi.getAllModels(); return response; }, + enabled, staleTime: 1000 * 60 * 5, // 5 minutes refetchOnWindowFocus: false, }); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useConversations.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useConversations.ts index eeace3584dc..bc37497362e 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useConversations.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useConversations.ts @@ -22,7 +22,9 @@ import { lightspeedApiRef } from '../api/api'; import { ConversationList } from '../types'; // Fetch all conversations -export const useConversations = (): UseQueryResult => { +export const useConversations = ( + enabled = true, +): UseQueryResult => { const lightspeedApi = useApi(lightspeedApiRef); return useQuery({ queryKey: ['conversations'], @@ -30,6 +32,7 @@ export const useConversations = (): UseQueryResult => { const response = await lightspeedApi.getConversations(); return response; }, + enabled, refetchInterval: query => { const data = query.state.data; if (!data?.length) return false; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedUpdatePermission.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useIaChatPermission.ts similarity index 80% rename from workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedUpdatePermission.ts rename to workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useIaChatPermission.ts index a8c7b71c571..bc15497f4aa 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedUpdatePermission.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useIaChatPermission.ts @@ -18,10 +18,16 @@ import { usePermission } from '@backstage/plugin-permission-react'; import { iaChatPermission } from '@red-hat-developer-hub/backstage-plugin-intelligent-assistant-common'; -export const useLightspeedUpdatePermission = () => { - const lightspeedUpdatePermissionResult = usePermission({ +export const useIaChatPermission = (): { + loading: boolean; + allowed: boolean; +} => { + const result = usePermission({ permission: iaChatPermission, }); - return lightspeedUpdatePermissionResult; + return { + loading: result.loading, + allowed: result.allowed, + }; }; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedViewPermission.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useIaMcpToolsPermission.ts similarity index 66% rename from workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedViewPermission.ts rename to workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useIaMcpToolsPermission.ts index f310c843df6..d067dce7ddb 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedViewPermission.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useIaMcpToolsPermission.ts @@ -16,20 +16,18 @@ import { usePermission } from '@backstage/plugin-permission-react'; -import { iaChatPermission } from '@red-hat-developer-hub/backstage-plugin-intelligent-assistant-common'; +import { iaMcpToolsPermission } from '@red-hat-developer-hub/backstage-plugin-intelligent-assistant-common'; -export const useLightspeedViewPermission = (): { +export const useIaMcpToolsPermission = (): { loading: boolean; allowed: boolean; - iaChatPermissionName: string; } => { - const canUseChats = usePermission({ - permission: iaChatPermission, + const result = usePermission({ + permission: iaMcpToolsPermission, }); return { - loading: canUseChats.loading, - allowed: canUseChats.allowed, - iaChatPermissionName: iaChatPermission.name, + loading: result.loading, + allowed: result.allowed, }; }; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/notebooks/useLightspeedNotebooksPermission.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useIaNotebooksPermission.ts similarity index 88% rename from workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/notebooks/useLightspeedNotebooksPermission.ts rename to workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useIaNotebooksPermission.ts index decc0666373..0f73dcead1e 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/notebooks/useLightspeedNotebooksPermission.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useIaNotebooksPermission.ts @@ -18,7 +18,10 @@ import { usePermission } from '@backstage/plugin-permission-react'; import { iaNotebooksPermission } from '@red-hat-developer-hub/backstage-plugin-intelligent-assistant-common'; -export const useLightspeedNotebooksPermission = () => { +export const useIaNotebooksPermission = (): { + loading: boolean; + allowed: boolean; +} => { const result = usePermission({ permission: iaNotebooksPermission, }); @@ -26,6 +29,5 @@ export const useLightspeedNotebooksPermission = () => { return { loading: result.loading, allowed: result.allowed, - iaNotebooksPermissionName: iaNotebooksPermission.name, }; }; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedDeletePermission.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useIaSkillsPermission.ts similarity index 66% rename from workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedDeletePermission.ts rename to workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useIaSkillsPermission.ts index d9504639706..edf8ba1d8f2 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedDeletePermission.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useIaSkillsPermission.ts @@ -16,12 +16,18 @@ import { usePermission } from '@backstage/plugin-permission-react'; -import { iaChatPermission } from '@red-hat-developer-hub/backstage-plugin-intelligent-assistant-common'; +import { iaSkillsPermission } from '@red-hat-developer-hub/backstage-plugin-intelligent-assistant-common'; -export const useLightspeedDeletePermission = () => { - const lightspeedDeletePermissionResult = usePermission({ - permission: iaChatPermission, +export const useIaSkillsPermission = (): { + loading: boolean; + allowed: boolean; +} => { + const result = usePermission({ + permission: iaSkillsPermission, }); - return lightspeedDeletePermissionResult; + return { + loading: result.loading, + allowed: result.allowed, + }; }; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useMcpConfigureModal.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useMcpConfigureModal.ts index 1d18f648ba0..3915290eba4 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useMcpConfigureModal.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useMcpConfigureModal.ts @@ -64,7 +64,6 @@ export type McpCredentialsValidationResult = { export type UseMcpConfigureModalOptions = { servers: McpConfigureServer[]; - canManageMcp: boolean; isSaving: Record; patchServer: ( serverName: string, @@ -86,7 +85,6 @@ export type UseMcpConfigureModalOptions = { */ export const useMcpConfigureModal = ({ servers, - canManageMcp, isSaving, patchServer, validateServer, @@ -318,12 +316,7 @@ export const useMcpConfigureModal = ({ }); const removePersonalToken = useCallback(async () => { - if ( - !editingServer || - !canManageMcp || - isUpdatingModalStatus || - editingServer.hasOrgToken - ) { + if (!editingServer || isUpdatingModalStatus || editingServer.hasOrgToken) { return; } @@ -353,7 +346,6 @@ export const useMcpConfigureModal = ({ setIsUpdatingModalStatus(false); } }, [ - canManageMcp, editingServer, editingServerId, isUpdatingModalStatus, @@ -362,7 +354,7 @@ export const useMcpConfigureModal = ({ ]); const save = useCallback(async () => { - if (!editingServer || !canManageMcp) return; + if (!editingServer) return; const hasCredentialModeChange = modalCredentialMode !== initialCredentialMode; @@ -485,7 +477,6 @@ export const useMcpConfigureModal = ({ markFailedTokenAttempt(); } }, [ - canManageMcp, close, editingServer, initialCredentialMode, @@ -501,12 +492,12 @@ export const useMcpConfigureModal = ({ const onModalEnabledChange = useCallback( (_event: FormEvent, checked: boolean) => { - if (!editingServer || !canManageMcp) { + if (!editingServer) { return; } setModalEnabled(checked); }, - [editingServer, canManageMcp], + [editingServer], ); const modalVerifiedHasToken = editingServer @@ -533,7 +524,6 @@ export const useMcpConfigureModal = ({ : 'unknown'; const isModalEnabledToggleDisabled = - !canManageMcp || !editingServer || isUpdatingModalStatus || isEnabledToggleUnavailable(modalDisplayStatus) || @@ -599,7 +589,6 @@ export const useMcpConfigureModal = ({ close, save, removePersonalToken, - canManageMcp, configureModalTitle, isConfigureModalSaving, isSaveTokenButtonDisabled, diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useNotebookConversationIds.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useNotebookConversationIds.ts index dc49594ed3e..e89724d0763 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useNotebookConversationIds.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useNotebookConversationIds.ts @@ -24,10 +24,9 @@ import { lightspeedApiRef } from '../api/api'; * Hook to fetch conversation IDs associated with notebook sessions for filtering * Works even when notebooks feature is disabled */ -export const useNotebookConversationIds = (): UseQueryResult< - string[], - Error -> => { +export const useNotebookConversationIds = ( + enabled = true, +): UseQueryResult => { const lightspeedApi = useApi(lightspeedApiRef); return useQuery({ @@ -35,6 +34,7 @@ export const useNotebookConversationIds = (): UseQueryResult< queryFn: async () => { return await lightspeedApi.getNotebookConversationIds(); }, + enabled, staleTime: 1000 * 60 * 5, // 5 minutes retry: false, }); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useQuestionValidation.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useQuestionValidation.ts index 794cbd50c1e..0215ddc2be1 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useQuestionValidation.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useQuestionValidation.ts @@ -20,12 +20,15 @@ import { useQuery, type UseQueryResult } from '@tanstack/react-query'; import { lightspeedApiRef } from '../api/api'; -export const useTopicRestrictionStatus = (): UseQueryResult => { +export const useTopicRestrictionStatus = ( + enabled = true, +): UseQueryResult => { const lightspeedApi = useApi(lightspeedApiRef); return useQuery({ queryKey: ['topicRestrictionStatus'], queryFn: async () => { return await lightspeedApi.isTopicRestrictionEnabled(); }, + enabled, }); }; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useWelcomePrompts.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useWelcomePrompts.ts index 43948060c96..3b678705f47 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useWelcomePrompts.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useWelcomePrompts.ts @@ -24,10 +24,11 @@ import { getRandomSamplePrompts } from '../utils/prompt-utils'; import { useTopicRestrictionStatus } from './useQuestionValidation'; import { useTranslation } from './useTranslation'; -export const useWelcomePrompts = (): SamplePrompts => { +export const useWelcomePrompts = (enabled = true): SamplePrompts => { const configApi: ConfigApi = useApi(configApiRef); const { t } = useTranslation(); - const { data: questionValidationEnabled } = useTopicRestrictionStatus(); + const { data: questionValidationEnabled } = + useTopicRestrictionStatus(enabled); return useMemo(() => { // Transform translation keys to actual prompts diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/images/permission-required.svg b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/images/permission-required.svg deleted file mode 100644 index 162999d709a..00000000000 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/images/permission-required.svg +++ /dev/null @@ -1,319 +0,0 @@ - - - Manage cloud services illustration - Gray -cloud, public, private, data, applications, managed platform, hybrid cloud, connections - - - - - Illustration - Full - Illustration - alwilker@redhat.com - 2024-11-25T16:17:18.697Z - 2024-11-25T16:17:18.697Z - pending - TRA267cb102-85d8-4bd4-8e0b-63d792ebcda0 - yes - true - pending - 2024-11-25T16:17:34.114Z - rhcc-audience:internal - no - square - yes - DER267cb102-85d8-4bd4-8e0b-63d792ebcda0 - no - - - colorway:gray - colorway:red - - - 2024-11-25T16:20:30.487Z - image/svg+xml - - - Manage cloud services illustration - Gray - - - - - cloud, public, private, data, applications, managed platform, hybrid cloud, connections - - - 108 - 108 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/de.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/de.ts index fb7f8b237c2..c68a1849385 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/de.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/de.ts @@ -115,7 +115,6 @@ const intelligentAssistantTranslationDe = createTranslationMessages({ 'footer.accuracy.label': 'KI-generierte Inhalte sollten vor der Verwendung stets überprüft werden.', 'icon.lightspeed.alt': 'Symbol des intelligenten Assistenten', - 'icon.permissionRequired.alt': "Symbol für 'Berechtigung erforderlich'", 'lcore.loadError.description': 'Das Backend des intelligenten Assistenten hat keine Modellliste zurückgegeben. Prüfen Sie, ob der Dienst läuft und erreichbar ist, und versuchen Sie es erneut.', 'lcore.loadError.title': 'Modelle konnten nicht geladen werden', @@ -165,8 +164,6 @@ const intelligentAssistantTranslationDe = createTranslationMessages({ 'mcp.settings.name': 'Name', 'mcp.settings.noneAvailable': 'Keine MCP-Server verfügbar.', 'mcp.settings.personalAccessToken': 'Persönlicher Zugriffstoken', - 'mcp.settings.readOnlyAccess': - 'Sie haben schreibgeschützten Zugriff auf MCP-Server.', 'mcp.settings.removePersonalToken': 'Persönlichen Token entfernen', 'mcp.settings.savedToken': 'Gespeicherter Token', 'mcp.settings.selectedCount': @@ -295,13 +292,6 @@ const intelligentAssistantTranslationDe = createTranslationMessages({ 'notebooks.updated.yesterday': 'Vor 1 Tag aktualisiert', 'page.subtitle': 'KI-gestützter Entwicklungsassistent', 'page.title': 'Intelligenter Assistent', - 'permission.notebooks.goBack': 'Zurück', - 'permission.required.description': - 'Um anzuzeigen, wenden Sie sich an Ihren Administrator, um die Berechtigung zu erhalten.', - 'permission.required.title': 'Fehlende Berechtigungen', - 'permission.subject.notebooks': - 'die Notizbücher des intelligenten Assistenten', - 'permission.subject.plugin': 'das Plugin des intelligenten Assistenten', 'prompts.codeOptimization.message': 'Können Sie gängige Methoden zur Codeoptimierung vorschlagen, um eine bessere Performance zu erzielen?', 'prompts.codeOptimization.title': diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/es.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/es.ts index ef5c96c9e8b..1829ff93e41 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/es.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/es.ts @@ -112,7 +112,6 @@ const intelligentAssistantTranslationEs = createTranslationMessages({ 'footer.accuracy.label': 'Revise siempre el contenido generado con IA antes de usarlo.', 'icon.lightspeed.alt': 'icono del asistente inteligente', - 'icon.permissionRequired.alt': 'icono de permiso requerido', 'lcore.loadError.description': 'El backend del asistente inteligente no devolvió una lista de modelos. Compruebe que el servicio está en ejecución y es accesible, e inténtelo de nuevo.', 'lcore.loadError.title': 'No se pudieron cargar los modelos', @@ -162,8 +161,6 @@ const intelligentAssistantTranslationEs = createTranslationMessages({ 'mcp.settings.name': 'Nombre', 'mcp.settings.noneAvailable': 'No hay servidores MCP disponibles.', 'mcp.settings.personalAccessToken': 'Token de acceso personal', - 'mcp.settings.readOnlyAccess': - 'Tienes acceso de solo lectura a los servidores MCP.', 'mcp.settings.removePersonalToken': 'Eliminar token personal', 'mcp.settings.savedToken': 'Token guardado', 'mcp.settings.selectedCount': @@ -289,12 +286,6 @@ const intelligentAssistantTranslationEs = createTranslationMessages({ 'notebooks.updated.yesterday': 'Actualizado hace 1 día', 'page.subtitle': 'Asistente de desarrollo con tecnología de IA', 'page.title': 'Asistente inteligente', - 'permission.notebooks.goBack': 'Volver', - 'permission.required.description': - 'Para ver , contacta a tu administrador para que te otorgue el permiso .', - 'permission.required.title': 'Permisos faltantes', - 'permission.subject.notebooks': 'los cuadernos del asistente inteligente', - 'permission.subject.plugin': 'el plugin del asistente inteligente', 'prompts.codeOptimization.message': '¿Puedes sugerir formas comunes de optimizar el código para lograr un mejor rendimiento?', 'prompts.codeOptimization.title': 'Sugerir optimizaciones de código', diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/fr.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/fr.ts index 3d2677bd551..6e053525a70 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/fr.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/fr.ts @@ -115,7 +115,6 @@ const intelligentAssistantTranslationFr = createTranslationMessages({ 'footer.accuracy.label': 'Toujours vérifier le contenu AI généré avant utilisation.', 'icon.lightspeed.alt': 'Icône de l\u2019assistant intelligent', - 'icon.permissionRequired.alt': 'icône d’autorisation requise', 'lcore.loadError.description': "Le backend de l\u2019assistant intelligent n'a pas renvoyé de liste de modèles. Vérifiez que le service est démarré et joignable, puis réessayez.", 'lcore.loadError.title': 'Impossible de charger les modèles', @@ -164,8 +163,6 @@ const intelligentAssistantTranslationFr = createTranslationMessages({ 'mcp.settings.name': 'Nom', 'mcp.settings.noneAvailable': 'Aucun serveur MCP disponible.', 'mcp.settings.personalAccessToken': "Jeton d'accès personnel", - 'mcp.settings.readOnlyAccess': - 'Vous disposez d’un accès en lecture seule aux serveurs MCP.', 'mcp.settings.removePersonalToken': 'Supprimer le jeton personnel', 'mcp.settings.savedToken': 'Jeton enregistré', 'mcp.settings.selectedCount': @@ -296,13 +293,6 @@ const intelligentAssistantTranslationFr = createTranslationMessages({ 'notebooks.updated.yesterday': 'Mis à jour il y a 1 jour', 'page.subtitle': 'Assistant de développement AI-POWERED', 'page.title': 'Assistant intelligent', - 'permission.notebooks.goBack': 'Retour', - 'permission.required.description': - "Pour afficher , veuillez contacter votre administrateur pour qu'il vous donne la permission .", - 'permission.required.title': 'Autorisations manquantes', - 'permission.subject.notebooks': - 'les carnets de l\u2019assistant intelligent', - 'permission.subject.plugin': 'le plugin de l\u2019assistant intelligent', 'prompts.codeOptimization.message': 'Pourriez-vous me suggérer les façons d’optimiser le code pour le rendre plus performant ?', 'prompts.codeOptimization.title': 'Suggestions d’Optmisation de Code', diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/it.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/it.ts index a2a43fdab51..c93041cca70 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/it.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/it.ts @@ -113,7 +113,6 @@ const intelligentAssistantTranslationIt = createTranslationMessages({ 'footer.accuracy.label': "Esaminare sempre i contenuti generati dall'intelligenza artificiale prima di utilizzarli.", 'icon.lightspeed.alt': "icona dell'assistente intelligente", - 'icon.permissionRequired.alt': 'icona di autorizzazione richiesta', 'lcore.loadError.description': "Il backend dell'assistente intelligente non ha restituito un elenco di modelli. Verifica che il servizio sia in esecuzione e raggiungibile, quindi riprova.", 'lcore.loadError.title': 'Impossibile caricare i modelli', @@ -162,8 +161,6 @@ const intelligentAssistantTranslationIt = createTranslationMessages({ 'mcp.settings.name': 'Nome', 'mcp.settings.noneAvailable': 'Nessun server MCP disponibile.', 'mcp.settings.personalAccessToken': 'Token di accesso personale', - 'mcp.settings.readOnlyAccess': - "Disponi dell'accesso in sola lettura ai server MCP.", 'mcp.settings.removePersonalToken': 'Rimuovi token personale', 'mcp.settings.savedToken': 'Token salvato', 'mcp.settings.selectedCount': @@ -294,12 +291,6 @@ const intelligentAssistantTranslationIt = createTranslationMessages({ 'page.subtitle': "Assistente allo sviluppo basato sull'intelligenza artificiale", 'page.title': 'Assistente intelligente', - 'permission.notebooks.goBack': 'Torna indietro', - 'permission.required.description': - "Per visualizzare , contattare l'amministratore per ottenere l'autorizzazione .", - 'permission.required.title': 'Autorizzazioni mancanti', - 'permission.subject.notebooks': "i quaderni dell'assistente intelligente", - 'permission.subject.plugin': "il plugin dell'assistente intelligente", 'prompts.codeOptimization.message': 'Puoi suggerirmi metodi comuni per ottimizzare il codice e ottenere prestazioni migliori?', 'prompts.codeOptimization.title': 'Suggerimenti per ottimizzare il codice', diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ja.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ja.ts index 169eb19c20e..00cf35b9a72 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ja.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ja.ts @@ -112,7 +112,6 @@ const intelligentAssistantTranslationJa = createTranslationMessages({ 'footer.accuracy.label': 'AI によって生成されたコンテンツは、使用する前に必ず確認してください。', 'icon.lightspeed.alt': 'インテリジェントアシスタントアイコン', - 'icon.permissionRequired.alt': '権限不足アイコン', 'lcore.loadError.description': 'インテリジェントアシスタントバックエンドがモデル一覧を返しませんでした。サービスが実行中で到達可能か確認してから、もう一度お試しください。', 'lcore.loadError.title': 'モデルを読み込めませんでした', @@ -159,8 +158,6 @@ const intelligentAssistantTranslationJa = createTranslationMessages({ 'mcp.settings.name': '名前', 'mcp.settings.noneAvailable': '利用可能な MCP サーバーはありません。', 'mcp.settings.personalAccessToken': '個人アクセストークン', - 'mcp.settings.readOnlyAccess': - 'MCP サーバーへのアクセスは読み取り専用です。', 'mcp.settings.removePersonalToken': '個人トークンを削除', 'mcp.settings.savedToken': '保存済みトークン', 'mcp.settings.selectedCount': @@ -286,12 +283,6 @@ const intelligentAssistantTranslationJa = createTranslationMessages({ 'notebooks.updated.yesterday': '1日前に更新', 'page.subtitle': 'AI 搭載開発アシスタント', 'page.title': 'インテリジェントアシスタント', - 'permission.notebooks.goBack': '戻る', - 'permission.required.description': - ' を表示するには、管理者に連絡して 権限を付与してもらうよう依頼してください。', - 'permission.required.title': '権限の不足', - 'permission.subject.notebooks': 'インテリジェントアシスタントノートブック', - 'permission.subject.plugin': 'インテリジェントアシスタントプラグイン', 'prompts.codeOptimization.message': 'コードを最適化してパフォーマンスを向上させるための一般的な方法を提案してくれませんか?', 'prompts.codeOptimization.title': 'コードの最適化を提案する', diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ref.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ref.ts index 0dc05cb90dd..9e4e34a5249 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ref.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ref.ts @@ -178,14 +178,6 @@ export const intelligentAssistantMessages = { 'conversation.rename.confirm.action': 'Rename', 'conversation.rename.placeholder': 'Chat name', - // Permissions - 'permission.required.title': 'Missing permissions', - 'permission.required.description': - 'To view , contact your administrator to give the permission.', - 'permission.subject.plugin': 'the intelligent assistant plugin', - 'permission.subject.notebooks': 'the intelligent assistant notebooks', - 'permission.notebooks.goBack': 'Go back', - // LCORE / LLM (no models registered) 'lcore.notConfigured.title': 'Connect an LLM to get started', 'lcore.notConfigured.description': @@ -295,7 +287,6 @@ export const intelligentAssistantMessages = { // Alt texts for icons 'icon.lightspeed.alt': 'intelligent assistant icon', - 'icon.permissionRequired.alt': 'permission required icon', // Message utilities 'message.options.label': 'Options', @@ -349,7 +340,6 @@ export const intelligentAssistantMessages = { 'mcp.settings.title': 'MCP servers', 'mcp.settings.selectedCount': '{{selectedCount}} of {{totalCount}} selected', 'mcp.settings.closeAriaLabel': 'Close MCP settings', - 'mcp.settings.readOnlyAccess': 'You have read-only access to MCP servers.', 'mcp.settings.tableAriaLabel': 'MCP servers table', 'mcp.settings.enabled': 'Enabled', 'mcp.settings.name': 'Name', diff --git a/workspaces/intelligent-assistant/rbac-policy.e2e.csv b/workspaces/intelligent-assistant/rbac-policy.e2e.csv new file mode 100644 index 00000000000..988a68b298d --- /dev/null +++ b/workspaces/intelligent-assistant/rbac-policy.e2e.csv @@ -0,0 +1,10 @@ +# Minimal RBAC policy for Playwright. Guest has no IA role; permission matrix +# scenarios are driven by the e2e authorize route mock. +p, role:default/intelligent-assistant-user, intelligent-assistant.chat, use, allow +p, role:default/intelligent-assistant-user, intelligent-assistant.notebooks, use, allow +p, role:default/intelligent-assistant-user, intelligent-assistant.mcp.tools, use, allow +p, role:default/intelligent-assistant-user, intelligent-assistant.skills, use, allow +p, role:default/intelligent-assistant-user, catalog.entity.read, read, allow +p, role:default/intelligent-assistant-user, catalog.location.read, read, allow + +g, user:development/guest, role:default/intelligent-assistant-user