From 773de695f0c3785b8d150e14156668a5e1c4952c Mon Sep 17 00:00:00 2001 From: Chin Rock Date: Wed, 4 Nov 2026 11:09:31 +0000 Subject: [PATCH] feat(ui): polish plugin permission review Show readable names and descriptions for capabilities and workspace operations, group grants by category, inline risk badges, and summarize every grant in the confirmation dialog. Replace the raw details list on the installed page with a compact pending-confirmation banner that links to the plugin detail page. Co-Authored-By: Claude Opus 5.5 Co-authored-by: multica-agent --- .../pkg/api/application/plugin_permissions.py | 6 +- .../plugins/components/PluginPermissions.tsx | 339 +++++++++++++----- .../plugins/components/permission-grants.ts | 1 + .../PluginInstalledComponent.tsx | 83 +++-- .../i18n/locales/plugin-permissions/en-US.ts | 71 ++++ .../i18n/locales/plugin-permissions/es-ES.ts | 74 ++++ .../i18n/locales/plugin-permissions/ja-JP.ts | 74 ++++ .../i18n/locales/plugin-permissions/ru-RU.ts | 74 ++++ .../i18n/locales/plugin-permissions/th-TH.ts | 68 ++++ .../i18n/locales/plugin-permissions/vi-VN.ts | 65 ++++ .../locales/plugin-permissions/zh-Hans.ts | 47 +++ .../locales/plugin-permissions/zh-Hant.ts | 44 +++ web/tests/e2e/plugin-permissions.spec.ts | 25 +- web/tests/unit/plugin-permissions.test.mjs | 14 +- 14 files changed, 856 insertions(+), 129 deletions(-) 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, );