diff --git a/src/langbot/pkg/api/application/plugin_permissions.py b/src/langbot/pkg/api/application/plugin_permissions.py index 09e737377..c4e625dde 100644 --- a/src/langbot/pkg/api/application/plugin_permissions.py +++ b/src/langbot/pkg/api/application/plugin_permissions.py @@ -226,8 +226,11 @@ def describe_permissions(ap, context, setting) -> dict: if category == 'workspace_operations': spec = ap.application_api._operations[name] required = frozenset({spec.permission}) - risk = get_operation_doc(name, side_effect=spec.side_effect).risk + doc = get_operation_doc(name, side_effect=spec.side_effect) + risk = doc.risk + label = doc.label else: + label = None required = CAPABILITY_PERMISSIONS[name] risk = ( 'dangerous' if name in DANGEROUS_CAPABILITIES else 'write' if name == 'bot.message.send' else 'read' @@ -236,6 +239,7 @@ def describe_permissions(ap, context, setting) -> dict: { 'id': name, 'category': category, + 'label': label, 'required_permissions': sorted(required), 'risk': risk, 'granted': name in grants[category], diff --git a/web/src/app/home/plugins/components/PluginPermissions.tsx b/web/src/app/home/plugins/components/PluginPermissions.tsx index e45220061..439fb9817 100644 --- a/web/src/app/home/plugins/components/PluginPermissions.tsx +++ b/web/src/app/home/plugins/components/PluginPermissions.tsx @@ -1,13 +1,23 @@ import { useCallback, useEffect, useId, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; +import { Loader2, ShieldAlert, ShieldCheck, TriangleAlert } from 'lucide-react'; import { backendClient } from '@/app/infra/http'; +import { extractI18nObject } from '@/i18n/I18nProvider'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { cn } from '@/lib/utils'; import { permissionGrantRequest, + type PermissionCategory, type PluginPermissionItem, type PluginPermissionState, } from './permission-grants'; @@ -20,6 +30,15 @@ import { DialogFooter, } from '@/components/ui/dialog'; +const CATEGORIES: PermissionCategory[] = [ + 'workspace_operations', + 'capabilities', +]; + +function needsAttention(item: PluginPermissionItem) { + return item.risk === 'dangerous' || item.id === 'bot.message.send'; +} + export default function PluginPermissions({ author, name, @@ -79,6 +98,24 @@ export default function PluginPermissions({ } } + function labelFor(item: PluginPermissionItem) { + if (item.category === 'capabilities') { + const key = `pluginPermissions.capability.${item.id.replace(/\./g, '_')}`; + return { + label: t(`${key}.label`, { defaultValue: item.id }), + description: t(`${key}.description`, { defaultValue: '' }), + }; + } + return { + label: item.label + ? extractI18nObject( + item.label as unknown as Parameters[0], + ) || item.id + : item.id, + description: '', + }; + } + if (failed) return ( - )} - - {item.risk === 'dangerous' && ( - - {t('pluginPermissions.dangerous')} - - )} - {item.id === 'bot.message.send' && ( - - {t('pluginPermissions.sendsMessages')} - - )} - {!item.grantable && !item.granted && ( -

- {t('pluginPermissions.requires', { - permissions: item.required_permissions.join(', '), + {state.permissions.length === 0 && ( +

+ {t('pluginPermissions.empty')} +

+ )} +
+ {CATEGORIES.map((category) => { + const items = state.permissions.filter( + (item) => item.category === category, + ); + if (items.length === 0) return null; + return ( +
+

+ {t( + category === 'capabilities' + ? 'pluginPermissions.capabilities' + : 'pluginPermissions.workspaceOperations', + )} + ({items.length}) +

+
    + {items.map((item) => { + const { label, description } = labelFor(item); + const inputId = `${inputPrefix}-${item.id}`; + return ( +
  • + + setSelected((previous) => { + const next = new Set(previous); + if (checked) next.add(item.id); + else next.delete(item.id); + return next; + }) + } + /> +
    +
    + + {item.risk === 'dangerous' && ( + + {t('pluginPermissions.dangerous')} + + )} + {item.id === 'bot.message.send' && ( + + {t('pluginPermissions.sendsMessages')} + + )} +
    + {label !== item.id && ( +

    + {item.id} +

    + )} + {description && ( +

    + {description} +

    + )} + {!item.grantable && !item.granted && ( +

    + {t('pluginPermissions.requires', { + permissions: + item.required_permissions.join(', '), + })} +

    + )} +
    + {item.granted && ( +
    + + {t('pluginPermissions.granted')} + + +
    + )} +
  • + ); })} -

    - )} -
- ))} + + + ); + })} + +
+
- - + {t('pluginPermissions.confirm')} @@ -200,22 +336,49 @@ export default function PluginPermissions({ )} -
    - {state.permissions - .filter( - (item) => - (state.compatibility_mode || selected.has(item.id)) && - (item.risk === 'dangerous' || - item.id === 'bot.message.send'), - ) +

    + {t('pluginPermissions.grantSummary', { count: toGrant.length })} +

    + {attention.length > 0 && ( +
    +

    + + {t('pluginPermissions.highlighted')} +

    +
      + {attention.map((item) => ( +
    • + {labelFor(item).label} + + {item.id} + +
    • + ))} +
    +
    + )} +
      + {toGrant + .filter((item) => !needsAttention(item)) .map((item) => ( -
    • - {item.id} +
    • + + {labelFor(item).label} + + {item.id}
    • ))}
    + diff --git a/web/src/app/home/plugins/components/permission-grants.ts b/web/src/app/home/plugins/components/permission-grants.ts index cc10854fa..2d7eaab14 100644 --- a/web/src/app/home/plugins/components/permission-grants.ts +++ b/web/src/app/home/plugins/components/permission-grants.ts @@ -3,6 +3,7 @@ export type PermissionCategory = 'workspace_operations' | 'capabilities'; export type PluginPermissionItem = { id: string; category: PermissionCategory; + label?: Record | null; required_permissions: string[]; risk: 'read' | 'write' | 'dangerous'; granted: boolean; diff --git a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx index aadad4682..eeee49fe0 100644 --- a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { ExtensionCardVO, ExtensionType } from './ExtensionCardVO'; import ExtensionCardComponent from './ExtensionCardComponent'; import PluginSpaceUploadDialog from './PluginSpaceUploadDialog'; -import PluginPermissions from '../PluginPermissions'; +import { Badge } from '@/components/ui/badge'; import styles from '@/app/home/plugins/plugins.module.css'; import { httpClient } from '@/app/infra/http/HttpClient'; import { getCloudServiceClientSync } from '@/app/infra/http'; @@ -23,7 +23,14 @@ import { extractI18nObject } from '@/i18n/I18nProvider'; import { toast } from 'sonner'; import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; -import { Loader2, Puzzle, Search, Server, Sparkles } from 'lucide-react'; +import { + Loader2, + Puzzle, + Search, + Server, + ShieldAlert, + Sparkles, +} from 'lucide-react'; import { pluginTaskKey, usePluginInstallTasks, @@ -384,35 +391,61 @@ const PluginInstalledComponent = forwardRef< }); }; + const pendingPermissionExtensions = extensionList.filter( + (extension) => extension.permission_status?.pending_confirmation, + ); + return ( <> - {extensionList.some( - (extension) => extension.permission_status?.pending_confirmation, - ) && ( + {pendingPermissionExtensions.length > 0 && (
    -

    {t('pluginPermissions.pending')}

    - {extensionList - .filter( - (extension) => extension.permission_status?.pending_confirmation, - ) - .map((extension) => ( -
    - - {extension.label} — {t('pluginPermissions.review')} - - { - getExtensionList(true); - refreshPlugins(); - }} - /> -
    +
    + +

    + {t('pluginPermissions.pending')} +

    + + {t('pluginPermissions.pendingCount', { + count: pendingPermissionExtensions.length, + })} + +
    +
      + {pendingPermissionExtensions.map((extension) => ( +
    • +
      + + {extension.label} + + + {extension.author}/{extension.name} + + {extension.permission_status?.compatibility_mode && ( + + {t('pluginPermissions.undeclared')} + + )} +
      + +
    • ))} +
    )} writes.length).toBe(1); expect(writes[0].capabilities).toHaveLength(16); expect(writes[0].workspace_operations).toEqual([]); - const row = page - .locator('div.rounded-md.border.p-2') - .filter({ has: page.getByText('bot.message.send', { exact: true }) }); + const row = page.locator('li[data-permission-id="bot.message.send"]'); await row.getByRole('button', { name: 'Revoke', exact: true }).click(); await expect.poll(() => writes.length).toBe(2); expect(writes[1].confirm).toBe(false); @@ -252,7 +255,7 @@ test('upgrade preserves existing grants and confirms newly declared permissions' page.getByText('Permission confirmation required', { exact: true }), ).toBeVisible(); await expect( - page.getByRole('checkbox', { name: 'stats.basic', exact: true }), + page.getByRole('checkbox', { name: /\(stats\.basic\)/ }), ).toBeChecked(); await page .getByRole('button', { name: 'Review permissions', exact: true }) @@ -314,7 +317,7 @@ test('ungrantable strict permissions are disabled; compatibility cannot partiall await mockPermissions(page, { blocked: true }); await page.goto('/home/extensions?id=qa%2Fpermissions'); await expect( - page.getByRole('checkbox', { name: 'sandbox', exact: true }), + page.getByRole('checkbox', { name: /\(sandbox\)/ }), ).toBeDisabled(); await expect( page.getByRole('button', { name: 'Review permissions', exact: true }), diff --git a/web/tests/unit/plugin-permissions.test.mjs b/web/tests/unit/plugin-permissions.test.mjs index 231fc2df4..d682fdc4b 100644 --- a/web/tests/unit/plugin-permissions.test.mjs +++ b/web/tests/unit/plugin-permissions.test.mjs @@ -118,13 +118,19 @@ test('all eight permission dictionaries expose matching translation keys', () => }).outputText; const module = { exports: {} }; new Function('module', 'exports', compiledLocale)(module, module.exports); - const values = module.exports.default; - const keys = Object.keys(values).sort(); + const flatten = (value, prefix = '') => + Object.entries(value).flatMap(([key, item]) => + item && typeof item === 'object' + ? flatten(item, `${prefix}${key}.`) + : [[`${prefix}${key}`, item]], + ); + const entries = flatten(module.exports.default); + const keys = entries.map(([key]) => key).sort(); expected ??= keys; assert.deepEqual(keys, expected, locale); assert.ok( - Object.values(values).every( - (value) => typeof value === 'string' && value.length > 0, + entries.every( + ([, value]) => typeof value === 'string' && value.length > 0, ), locale, );