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 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Chin Rock
2026-11-04 11:09:31 +00:00
co-authored by Claude Opus 5.5 multica-agent
parent cc7ecfedaa
commit 773de695f0
14 changed files with 856 additions and 129 deletions
@@ -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],
@@ -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<typeof extractI18nObject>[0],
) || item.id
: item.id,
description: '',
};
}
if (failed)
return (
<Button variant="outline" onClick={() => void load()}>
@@ -93,103 +130,202 @@ export default function PluginPermissions({
state.can_confirm &&
(state.permissions.length === 0 ||
state.permissions.some((item) => item.grantable));
const grantedCount = state.permissions.filter((item) => item.granted).length;
const toGrant = state.permissions.filter(
(item) => state.compatibility_mode || selected.has(item.id),
);
const attention = toGrant.filter(needsAttention);
return (
<Card>
<CardHeader>
<CardTitle>{t('pluginPermissions.title')}</CardTitle>
<Card className="gap-4">
<CardHeader className="gap-1.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-base">
{state.pending_confirmation ? (
<ShieldAlert className="size-4 text-amber-600" />
) : (
<ShieldCheck className="size-4 text-emerald-600" />
)}
{t('pluginPermissions.title')}
</CardTitle>
<div className="flex flex-wrap gap-1.5">
{state.compatibility_mode && (
<Badge
variant="outline"
className="border-amber-500/60 text-amber-700 dark:text-amber-400"
>
{t('pluginPermissions.undeclared')}
</Badge>
)}
</div>
</div>
{state.permissions.length > 0 && (
<CardDescription>
{t('pluginPermissions.grantedCount', {
granted: grantedCount,
total: state.permissions.length,
})}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-4">
{state.compatibility_mode && (
<div
role="alert"
className="rounded-md border border-amber-500 bg-amber-500/10 p-3 text-sm"
className="flex gap-2 rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-sm text-amber-900 dark:text-amber-200"
>
<Badge variant="outline">{t('pluginPermissions.undeclared')}</Badge>
<p className="mt-2">
{t('pluginPermissions.compatibilityWarning')}
</p>
<TriangleAlert className="mt-0.5 size-4 shrink-0" />
<p>{t('pluginPermissions.compatibilityWarning')}</p>
</div>
)}
{state.pending_confirmation && (
<p role="status" className="text-sm font-medium">
<p
role="status"
className="text-sm font-medium text-amber-700 dark:text-amber-400"
>
{t('pluginPermissions.pending')}
</p>
)}
{!canConfirm && (
<p role="alert" className="text-sm text-muted-foreground">
<p role="alert" className="text-xs text-muted-foreground">
{t('pluginPermissions.managerRequired')}
</p>
)}
<div className="max-h-96 space-y-3 overflow-y-auto">
{state.permissions.map((item) => (
<div
key={`${item.category}:${item.id}`}
className="space-y-1 rounded-md border p-2"
>
<div className="flex items-start gap-2">
<Checkbox
id={`${inputPrefix}-${item.id}`}
checked={selected.has(item.id)}
disabled={
busy ||
!state.can_manage ||
state.compatibility_mode ||
!item.grantable
}
onCheckedChange={(checked) =>
setSelected((previous) => {
const next = new Set(previous);
if (checked) next.add(item.id);
else next.delete(item.id);
return next;
})
}
/>
<label
htmlFor={`${inputPrefix}-${item.id}`}
className="flex-1 break-all font-mono text-xs"
>
{item.id}
</label>
{item.granted && (
<Button
size="sm"
variant="outline"
disabled={busy || !state.can_manage}
onClick={() => void save(false, item)}
>
{t('pluginPermissions.revoke')}
</Button>
)}
</div>
{item.risk === 'dangerous' && (
<Badge variant="destructive">
{t('pluginPermissions.dangerous')}
</Badge>
)}
{item.id === 'bot.message.send' && (
<Badge variant="outline">
{t('pluginPermissions.sendsMessages')}
</Badge>
)}
{!item.grantable && !item.granted && (
<p className="text-xs text-muted-foreground">
{t('pluginPermissions.requires', {
permissions: item.required_permissions.join(', '),
{state.permissions.length === 0 && (
<p className="text-sm text-muted-foreground">
{t('pluginPermissions.empty')}
</p>
)}
<div className="max-h-[28rem] space-y-4 overflow-y-auto pr-1">
{CATEGORIES.map((category) => {
const items = state.permissions.filter(
(item) => item.category === category,
);
if (items.length === 0) return null;
return (
<section key={category} className="space-y-2">
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t(
category === 'capabilities'
? 'pluginPermissions.capabilities'
: 'pluginPermissions.workspaceOperations',
)}
<span className="ml-1 tabular-nums">({items.length})</span>
</h4>
<ul className="divide-y rounded-md border">
{items.map((item) => {
const { label, description } = labelFor(item);
const inputId = `${inputPrefix}-${item.id}`;
return (
<li
key={`${item.category}:${item.id}`}
data-permission-id={item.id}
className={cn(
'flex items-start gap-3 px-3 py-2.5',
item.risk === 'dangerous' && 'bg-destructive/5',
)}
>
<Checkbox
id={inputId}
className="mt-0.5"
checked={selected.has(item.id)}
disabled={
busy ||
!state.can_manage ||
state.compatibility_mode ||
!item.grantable
}
onCheckedChange={(checked) =>
setSelected((previous) => {
const next = new Set(previous);
if (checked) next.add(item.id);
else next.delete(item.id);
return next;
})
}
/>
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex flex-wrap items-center gap-1.5">
<label
htmlFor={inputId}
className="text-sm font-medium leading-5"
>
{label}
<span className="sr-only"> ({item.id})</span>
</label>
{item.risk === 'dangerous' && (
<Badge
variant="destructive"
className="h-5 px-1.5 text-[11px]"
>
{t('pluginPermissions.dangerous')}
</Badge>
)}
{item.id === 'bot.message.send' && (
<Badge
variant="outline"
className="h-5 border-amber-500/60 px-1.5 text-[11px] text-amber-700 dark:text-amber-400"
>
{t('pluginPermissions.sendsMessages')}
</Badge>
)}
</div>
{label !== item.id && (
<p className="break-all font-mono text-[11px] text-muted-foreground">
{item.id}
</p>
)}
{description && (
<p className="text-xs text-muted-foreground">
{description}
</p>
)}
{!item.grantable && !item.granted && (
<p className="text-xs text-amber-700 dark:text-amber-400">
{t('pluginPermissions.requires', {
permissions:
item.required_permissions.join(', '),
})}
</p>
)}
</div>
{item.granted && (
<div className="flex shrink-0 items-center gap-1.5">
<Badge
variant="secondary"
className="h-5 px-1.5 text-[11px]"
>
{t('pluginPermissions.granted')}
</Badge>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
disabled={busy || !state.can_manage}
onClick={() => void save(false, item)}
>
{t('pluginPermissions.revoke')}
</Button>
</div>
)}
</li>
);
})}
</p>
)}
</div>
))}
</ul>
</section>
);
})}
</div>
<div className="flex justify-end">
<Button
disabled={busy || !canConfirm || compatibilityBlocked}
onClick={() => setConfirming(true)}
>
{t('pluginPermissions.review')}
</Button>
</div>
<Button
disabled={busy || !canConfirm || compatibilityBlocked}
onClick={() => setConfirming(true)}
>
{t('pluginPermissions.review')}
</Button>
<Dialog open={confirming} onOpenChange={setConfirming}>
<DialogContent>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('pluginPermissions.confirm')}</DialogTitle>
<DialogDescription>
@@ -200,22 +336,49 @@ export default function PluginPermissions({
)}
</DialogDescription>
</DialogHeader>
<ul className="max-h-60 space-y-1 overflow-y-auto text-sm">
{state.permissions
.filter(
(item) =>
(state.compatibility_mode || selected.has(item.id)) &&
(item.risk === 'dangerous' ||
item.id === 'bot.message.send'),
)
<p className="text-sm font-medium">
{t('pluginPermissions.grantSummary', { count: toGrant.length })}
</p>
{attention.length > 0 && (
<div className="space-y-1.5 rounded-md border border-destructive/40 bg-destructive/5 p-3">
<p className="flex items-center gap-1.5 text-xs font-medium text-destructive">
<TriangleAlert className="size-3.5" />
{t('pluginPermissions.highlighted')}
</p>
<ul className="space-y-1 text-sm">
{attention.map((item) => (
<li key={item.id} className="flex items-baseline gap-2">
<span>{labelFor(item).label}</span>
<span className="font-mono text-xs text-destructive">
{item.id}
</span>
</li>
))}
</ul>
</div>
)}
<ul className="max-h-48 space-y-1 overflow-y-auto text-sm text-muted-foreground">
{toGrant
.filter((item) => !needsAttention(item))
.map((item) => (
<li key={item.id} className="font-mono text-destructive">
{item.id}
<li key={item.id} className="flex items-baseline gap-2">
<span className="text-foreground">
{labelFor(item).label}
</span>
<span className="font-mono text-xs">{item.id}</span>
</li>
))}
</ul>
<DialogFooter>
<Button
variant="outline"
disabled={busy}
onClick={() => setConfirming(false)}
>
{t('common.cancel')}
</Button>
<Button disabled={busy} onClick={() => void save(true)}>
{busy && <Loader2 className="size-4 animate-spin" />}
{t('pluginPermissions.confirm')}
</Button>
</DialogFooter>
@@ -3,6 +3,7 @@ export type PermissionCategory = 'workspace_operations' | 'capabilities';
export type PluginPermissionItem = {
id: string;
category: PermissionCategory;
label?: Record<string, string> | null;
required_permissions: string[];
risk: 'read' | 'write' | 'dangerous';
granted: boolean;
@@ -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 && (
<section
aria-label={t('pluginPermissions.pending')}
className="mx-3 mb-4 space-y-3 rounded-md border border-amber-500 p-4"
className="mx-3 mb-4 rounded-lg border border-amber-500/50 bg-amber-500/5"
>
<h2 className="font-semibold">{t('pluginPermissions.pending')}</h2>
{extensionList
.filter(
(extension) => extension.permission_status?.pending_confirmation,
)
.map((extension) => (
<details key={extension.id}>
<summary className="cursor-pointer">
{extension.label} — {t('pluginPermissions.review')}
</summary>
<PluginPermissions
author={extension.author}
name={extension.name}
onChange={() => {
getExtensionList(true);
refreshPlugins();
}}
/>
</details>
<div className="flex items-center gap-2 px-4 pt-3 pb-2">
<ShieldAlert className="size-4 text-amber-600" />
<h2 className="text-sm font-semibold">
{t('pluginPermissions.pending')}
</h2>
<span className="text-xs text-muted-foreground">
{t('pluginPermissions.pendingCount', {
count: pendingPermissionExtensions.length,
})}
</span>
</div>
<ul className="divide-y divide-amber-500/20 border-t border-amber-500/20">
{pendingPermissionExtensions.map((extension) => (
<li
key={extension.id}
className="flex items-center justify-between gap-3 px-4 py-2"
>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">
{extension.label}
</span>
<span className="truncate font-mono text-xs text-muted-foreground">
{extension.author}/{extension.name}
</span>
{extension.permission_status?.compatibility_mode && (
<Badge
variant="outline"
className="h-5 shrink-0 border-amber-500/60 px-1.5 text-[11px] text-amber-700 dark:text-amber-400"
>
{t('pluginPermissions.undeclared')}
</Badge>
)}
</div>
<Button
size="sm"
variant="outline"
className="shrink-0"
onClick={() => handleExtensionClick(extension)}
>
{t('pluginPermissions.review')}
</Button>
</li>
))}
</ul>
</section>
)}
<Dialog
@@ -18,4 +18,75 @@ export default {
confirmDescription:
'Grant the selected permissions to this plugin installation. Dangerous permissions and bot messaging are highlighted.',
grantInPlugin: 'Grant this operation in plugin permissions first',
workspaceOperations: 'Workspace operations',
capabilities: 'Plugin capabilities',
granted: 'Granted',
grantedCount: '{{granted}} of {{total}} granted',
empty: 'This plugin does not request any permissions.',
pendingCount: 'Waiting for confirmation: {{count}}',
highlighted: 'Requires attention',
grantSummary: '{{count}} permissions will be granted',
capability: {
langbot_info: {
label: 'LangBot version',
description: 'Read the LangBot version',
},
bot_read: {
label: 'Read bots',
description: 'List bots and read bot details',
},
bot_message_send: {
label: 'Send bot messages',
description: 'Send messages through Workspace bots',
},
bot_platform_api: {
label: 'Platform API',
description: 'Call raw IM platform APIs through a bot',
},
models_read: {
label: 'Read models',
description: 'List configured models',
},
models_invoke: {
label: 'Invoke models',
description: 'Call LLM, embedding and rerank models',
},
storage_plugin: {
label: 'Plugin storage',
description: "Read and write this plugin's own storage",
},
storage_workspace: {
label: 'Workspace storage',
description: 'Read and write storage shared across the Workspace',
},
knowledge_read: {
label: 'Read knowledge',
description: 'List and retrieve knowledge bases',
},
knowledge_vector: {
label: 'Vector store',
description: 'Write, search and delete vectors',
},
knowledge_parser: {
label: 'Document parsers',
description: 'List and invoke document parsers',
},
config_read: {
label: 'Read config files',
description: 'Read uploaded plugin config files',
},
plugin_tools: {
label: 'Plugin tools',
description: 'List and call tools of other plugins',
},
mcp: { label: 'MCP', description: 'Call MCP tools and read MCP resources' },
sandbox: {
label: 'Sandbox',
description: 'Acquire and run code in a Box sandbox',
},
plugin_read: {
label: 'Read plugins',
description: 'List installed plugins and commands',
},
},
};
@@ -19,4 +19,78 @@ export default {
'Concede los permisos seleccionados a esta instalación. Se destacan los permisos peligrosos y el envío de mensajes.',
grantInPlugin:
'Concede primero esta operación en los permisos del complemento',
workspaceOperations: 'Operaciones del espacio de trabajo',
capabilities: 'Capacidades del plugin',
granted: 'Concedido',
grantedCount: '{{granted}} de {{total}} concedidos',
empty: 'Este plugin no solicita ningún permiso.',
pendingCount: 'Pendientes de confirmación: {{count}}',
highlighted: 'Requiere atención',
grantSummary: 'Se concederán {{count}} permisos',
capability: {
langbot_info: {
label: 'Versión de LangBot',
description: 'Leer la versión de LangBot',
},
bot_read: {
label: 'Leer bots',
description: 'Listar bots y leer sus detalles',
},
bot_message_send: {
label: 'Enviar mensajes',
description: 'Enviar mensajes a través de los bots',
},
bot_platform_api: {
label: 'API de plataforma',
description: 'Llamar a la API nativa de la plataforma IM',
},
models_read: {
label: 'Leer modelos',
description: 'Listar los modelos configurados',
},
models_invoke: {
label: 'Invocar modelos',
description: 'Llamar a modelos LLM, de embeddings y rerank',
},
storage_plugin: {
label: 'Almacenamiento del plugin',
description: 'Leer y escribir el almacenamiento propio',
},
storage_workspace: {
label: 'Almacenamiento compartido',
description: 'Leer y escribir almacenamiento compartido del espacio',
},
knowledge_read: {
label: 'Leer conocimiento',
description: 'Listar y consultar bases de conocimiento',
},
knowledge_vector: {
label: 'Almacén vectorial',
description: 'Escribir, buscar y borrar vectores',
},
knowledge_parser: {
label: 'Analizadores',
description: 'Listar e invocar analizadores de documentos',
},
config_read: {
label: 'Leer archivos de configuración',
description: 'Leer archivos de configuración subidos',
},
plugin_tools: {
label: 'Herramientas de plugins',
description: 'Listar y llamar herramientas de otros plugins',
},
mcp: {
label: 'MCP',
description: 'Llamar herramientas y leer recursos MCP',
},
sandbox: {
label: 'Sandbox',
description: 'Obtener un sandbox Box y ejecutar código',
},
plugin_read: {
label: 'Leer plugins',
description: 'Listar plugins y comandos instalados',
},
},
};
@@ -18,4 +18,78 @@ export default {
confirmDescription:
'選択した権限をこのプラグインに付与します。危険な権限とボット送信権限を強調表示しています。',
grantInPlugin: '先にプラグイン権限でこの操作を許可してください',
workspaceOperations: 'ワークスペース操作',
capabilities: 'プラグイン機能',
granted: '許可済み',
grantedCount: '{{total}} 件中 {{granted}} 件を許可済み',
empty: 'このプラグインは権限を要求していません。',
pendingCount: '{{count}} 個のプラグインが権限の確認を待っています',
highlighted: '要注意',
grantSummary: '{{count}} 件の権限を許可します',
capability: {
langbot_info: {
label: 'LangBot バージョン',
description: 'LangBot のバージョンを取得',
},
bot_read: {
label: 'ボットの読み取り',
description: 'ボット一覧と詳細を取得',
},
bot_message_send: {
label: 'ボットメッセージ送信',
description: 'ワークスペースのボットでメッセージを送信',
},
bot_platform_api: {
label: 'プラットフォーム API',
description: 'ボット経由で IM プラットフォームの API を直接呼び出す',
},
models_read: {
label: 'モデルの読み取り',
description: '設定済みモデルの一覧を取得',
},
models_invoke: {
label: 'モデルの呼び出し',
description: 'LLM・埋め込み・リランクモデルを呼び出す',
},
storage_plugin: {
label: 'プラグインストレージ',
description: 'プラグイン専用ストレージの読み書き',
},
storage_workspace: {
label: 'ワークスペースストレージ',
description: 'ワークスペース共有ストレージの読み書き',
},
knowledge_read: {
label: 'ナレッジの読み取り',
description: 'ナレッジベースの一覧と検索',
},
knowledge_vector: {
label: 'ベクトルストア',
description: 'ベクトルの書き込み・検索・削除',
},
knowledge_parser: {
label: 'ドキュメントパーサー',
description: 'パーサーの一覧と呼び出し',
},
config_read: {
label: '設定ファイルの読み取り',
description: 'アップロードされた設定ファイルを読む',
},
plugin_tools: {
label: 'プラグインツール',
description: '他のプラグインのツールを一覧・呼び出し',
},
mcp: {
label: 'MCP',
description: 'MCP ツールの呼び出しとリソースの読み取り',
},
sandbox: {
label: 'サンドボックス',
description: 'Box サンドボックスを確保してコードを実行',
},
plugin_read: {
label: 'プラグインの読み取り',
description: 'インストール済みプラグインとコマンドの一覧',
},
},
};
@@ -18,4 +18,78 @@ export default {
confirmDescription:
'Предоставить выбранные разрешения этому плагину. Опасные разрешения и отправка сообщений выделены отдельно.',
grantInPlugin: 'Сначала разрешите операцию в разрешениях плагина',
workspaceOperations: 'Операции рабочего пространства',
capabilities: 'Возможности плагина',
granted: 'Выдано',
grantedCount: 'Выдано {{granted}} из {{total}}',
empty: 'Плагин не запрашивает разрешений.',
pendingCount: 'Плагинов, ожидающих подтверждения: {{count}}',
highlighted: 'Требует внимания',
grantSummary: 'Будет выдано разрешений: {{count}}',
capability: {
langbot_info: {
label: 'Версия LangBot',
description: 'Читать версию LangBot',
},
bot_read: {
label: 'Чтение ботов',
description: 'Список ботов и их данные',
},
bot_message_send: {
label: 'Отправка сообщений',
description: 'Отправлять сообщения через ботов',
},
bot_platform_api: {
label: 'API платформы',
description: 'Вызывать нативный API IM-платформы через бота',
},
models_read: {
label: 'Чтение моделей',
description: 'Список настроенных моделей',
},
models_invoke: {
label: 'Вызов моделей',
description: 'Вызывать LLM, эмбеддинги и реранкинг',
},
storage_plugin: {
label: 'Хранилище плагина',
description: 'Чтение и запись собственного хранилища',
},
storage_workspace: {
label: 'Общее хранилище',
description: 'Чтение и запись общего хранилища пространства',
},
knowledge_read: {
label: 'Чтение знаний',
description: 'Список и поиск по базам знаний',
},
knowledge_vector: {
label: 'Векторное хранилище',
description: 'Запись, поиск и удаление векторов',
},
knowledge_parser: {
label: 'Парсеры документов',
description: 'Список и вызов парсеров',
},
config_read: {
label: 'Чтение конфигурации',
description: 'Читать загруженные файлы конфигурации',
},
plugin_tools: {
label: 'Инструменты плагинов',
description: 'Список и вызов инструментов других плагинов',
},
mcp: {
label: 'MCP',
description: 'Вызов инструментов и чтение ресурсов MCP',
},
sandbox: {
label: 'Песочница',
description: 'Получить песочницу Box и выполнять код',
},
plugin_read: {
label: 'Чтение плагинов',
description: 'Список установленных плагинов и команд',
},
},
};
@@ -18,4 +18,72 @@ export default {
confirmDescription:
'ให้สิทธิ์ที่เลือกแก่ปลั๊กอินนี้ โดยเน้นสิทธิ์อันตรายและการส่งข้อความบอตแยกไว้',
grantInPlugin: 'ให้สิทธิ์การดำเนินการนี้ในการตั้งค่าสิทธิ์ปลั๊กอินก่อน',
workspaceOperations: 'การดำเนินการในพื้นที่ทำงาน',
capabilities: 'ความสามารถของปลั๊กอิน',
granted: 'อนุญาตแล้ว',
grantedCount: 'อนุญาตแล้ว {{granted}} จาก {{total}}',
empty: 'ปลั๊กอินนี้ไม่ได้ขอสิทธิ์ใด ๆ',
pendingCount: 'มี {{count}} ปลั๊กอินรอยืนยันสิทธิ์',
highlighted: 'ต้องระวัง',
grantSummary: 'จะอนุญาต {{count}} สิทธิ์',
capability: {
langbot_info: {
label: 'เวอร์ชัน LangBot',
description: 'อ่านเวอร์ชัน LangBot',
},
bot_read: { label: 'อ่านบอท', description: 'แสดงรายการบอทและรายละเอียด' },
bot_message_send: {
label: 'ส่งข้อความบอท',
description: 'ส่งข้อความผ่านบอทในพื้นที่ทำงาน',
},
bot_platform_api: {
label: 'API แพลตฟอร์ม',
description: 'เรียก API ดั้งเดิมของแพลตฟอร์ม IM ผ่านบอท',
},
models_read: {
label: 'อ่านโมเดล',
description: 'แสดงรายการโมเดลที่ตั้งค่าไว้',
},
models_invoke: {
label: 'เรียกใช้โมเดล',
description: 'เรียกโมเดล LLM, embedding และ rerank',
},
storage_plugin: {
label: 'ที่เก็บข้อมูลปลั๊กอิน',
description: 'อ่านและเขียนที่เก็บข้อมูลของปลั๊กอินเอง',
},
storage_workspace: {
label: 'ที่เก็บข้อมูลพื้นที่ทำงาน',
description: 'อ่านและเขียนที่เก็บข้อมูลที่ใช้ร่วมกัน',
},
knowledge_read: {
label: 'อ่านฐานความรู้',
description: 'แสดงรายการและค้นคืนฐานความรู้',
},
knowledge_vector: {
label: 'ที่เก็บเวกเตอร์',
description: 'เขียน ค้นหา และลบเวกเตอร์',
},
knowledge_parser: {
label: 'ตัวแยกวิเคราะห์เอกสาร',
description: 'แสดงรายการและเรียกใช้ตัวแยกวิเคราะห์',
},
config_read: {
label: 'อ่านไฟล์การตั้งค่า',
description: 'อ่านไฟล์การตั้งค่าที่อัปโหลด',
},
plugin_tools: {
label: 'เครื่องมือปลั๊กอิน',
description: 'แสดงรายการและเรียกเครื่องมือของปลั๊กอินอื่น',
},
mcp: { label: 'MCP', description: 'เรียกเครื่องมือและอ่านทรัพยากร MCP' },
sandbox: {
label: 'แซนด์บ็อกซ์',
description: 'ขอแซนด์บ็อกซ์ Box และรันโค้ด',
},
plugin_read: {
label: 'อ่านปลั๊กอิน',
description: 'แสดงรายการปลั๊กอินและคำสั่งที่ติดตั้ง',
},
},
};
@@ -18,4 +18,69 @@ export default {
confirmDescription:
'Cấp các quyền đã chọn cho bản cài đặt plugin này. Quyền nguy hiểm và gửi tin nhắn bot được đánh dấu riêng.',
grantInPlugin: 'Cấp thao tác này trong quyền plugin trước',
workspaceOperations: 'Thao tác không gian làm việc',
capabilities: 'Khả năng của plugin',
granted: 'Đã cấp',
grantedCount: 'Đã cấp {{granted}} / {{total}}',
empty: 'Plugin này không yêu cầu quyền nào.',
pendingCount: '{{count}} plugin đang chờ xác nhận quyền',
highlighted: 'Cần lưu ý',
grantSummary: 'Sẽ cấp {{count}} quyền',
capability: {
langbot_info: {
label: 'Phiên bản LangBot',
description: 'Đọc phiên bản LangBot',
},
bot_read: { label: 'Đọc bot', description: 'Liệt kê bot và đọc chi tiết' },
bot_message_send: {
label: 'Gửi tin nhắn bot',
description: 'Gửi tin nhắn qua bot của không gian',
},
bot_platform_api: {
label: 'API nền tảng',
description: 'Gọi API gốc của nền tảng IM qua bot',
},
models_read: {
label: 'Đọc mô hình',
description: 'Liệt kê các mô hình đã cấu hình',
},
models_invoke: {
label: 'Gọi mô hình',
description: 'Gọi mô hình LLM, embedding và rerank',
},
storage_plugin: {
label: 'Lưu trữ plugin',
description: 'Đọc và ghi lưu trữ riêng của plugin',
},
storage_workspace: {
label: 'Lưu trữ không gian',
description: 'Đọc và ghi lưu trữ dùng chung',
},
knowledge_read: {
label: 'Đọc tri thức',
description: 'Liệt kê và truy xuất cơ sở tri thức',
},
knowledge_vector: {
label: 'Kho vector',
description: 'Ghi, tìm kiếm và xóa vector',
},
knowledge_parser: {
label: 'Trình phân tích tài liệu',
description: 'Liệt kê và gọi trình phân tích',
},
config_read: {
label: 'Đọc tệp cấu hình',
description: 'Đọc tệp cấu hình đã tải lên',
},
plugin_tools: {
label: 'Công cụ plugin',
description: 'Liệt kê và gọi công cụ của plugin khác',
},
mcp: { label: 'MCP', description: 'Gọi công cụ và đọc tài nguyên MCP' },
sandbox: { label: 'Sandbox', description: 'Xin sandbox Box và chạy mã' },
plugin_read: {
label: 'Đọc plugin',
description: 'Liệt kê plugin và lệnh đã cài',
},
},
};
@@ -18,4 +18,51 @@ export default {
confirmDescription:
'向此插件安装授予选中的权限。危险权限和机器人发消息能力已单独标出。',
grantInPlugin: '需先在插件权限中授予此操作',
workspaceOperations: '工作区操作',
capabilities: '插件能力',
granted: '已授予',
grantedCount: '已授予 {{granted}} / {{total}} 项',
empty: '该插件未申请任何权限。',
pendingCount: '{{count}} 个插件等待确认权限',
highlighted: '需留意',
grantSummary: '将授予 {{count}} 项权限',
capability: {
langbot_info: { label: 'LangBot 版本', description: '读取 LangBot 版本' },
bot_read: { label: '读取机器人', description: '列出机器人并读取详情' },
bot_message_send: {
label: '发送机器人消息',
description: '通过工作区机器人发送消息',
},
bot_platform_api: {
label: '平台 API',
description: '通过机器人调用 IM 平台原生 API',
},
models_read: { label: '读取模型', description: '列出已配置的模型' },
models_invoke: {
label: '调用模型',
description: '调用对话、嵌入和重排序模型',
},
storage_plugin: { label: '插件存储', description: '读写插件自身的存储' },
storage_workspace: {
label: '工作区存储',
description: '读写工作区内共享的存储',
},
knowledge_read: { label: '读取知识库', description: '列出并检索知识库' },
knowledge_vector: { label: '向量库', description: '写入、搜索和删除向量' },
knowledge_parser: {
label: '文档解析器',
description: '列出并调用文档解析器',
},
config_read: {
label: '读取配置文件',
description: '读取上传的插件配置文件',
},
plugin_tools: {
label: '插件工具',
description: '列出并调用其他插件的工具',
},
mcp: { label: 'MCP', description: '调用 MCP 工具、读取 MCP 资源' },
sandbox: { label: '沙盒', description: '申请 Box 沙盒并执行代码' },
plugin_read: { label: '读取插件', description: '列出已安装的插件和命令' },
},
};
@@ -18,4 +18,48 @@ export default {
confirmDescription:
'向此外掛安裝授予所選權限。危險權限和機器人傳訊能力已單獨標示。',
grantInPlugin: '請先在外掛權限中授予此操作',
workspaceOperations: '工作區操作',
capabilities: '外掛能力',
granted: '已授予',
grantedCount: '已授予 {{granted}} / {{total}} 項',
empty: '此外掛未申請任何權限。',
pendingCount: '{{count}} 個外掛等待確認權限',
highlighted: '需留意',
grantSummary: '將授予 {{count}} 項權限',
capability: {
langbot_info: { label: 'LangBot 版本', description: '讀取 LangBot 版本' },
bot_read: { label: '讀取機器人', description: '列出機器人並讀取詳情' },
bot_message_send: {
label: '傳送機器人訊息',
description: '透過工作區機器人傳送訊息',
},
bot_platform_api: {
label: '平台 API',
description: '透過機器人呼叫 IM 平台原生 API',
},
models_read: { label: '讀取模型', description: '列出已設定的模型' },
models_invoke: {
label: '呼叫模型',
description: '呼叫對話、嵌入和重排序模型',
},
storage_plugin: { label: '外掛儲存', description: '讀寫外掛自身的儲存' },
storage_workspace: {
label: '工作區儲存',
description: '讀寫工作區內共享的儲存',
},
knowledge_read: { label: '讀取知識庫', description: '列出並檢索知識庫' },
knowledge_vector: { label: '向量庫', description: '寫入、搜尋和刪除向量' },
knowledge_parser: {
label: '文件解析器',
description: '列出並呼叫文件解析器',
},
config_read: { label: '讀取設定檔', description: '讀取上傳的外掛設定檔' },
plugin_tools: {
label: '外掛工具',
description: '列出並呼叫其他外掛的工具',
},
mcp: { label: 'MCP', description: '呼叫 MCP 工具、讀取 MCP 資源' },
sandbox: { label: '沙盒', description: '申請 Box 沙盒並執行程式碼' },
plugin_read: { label: '讀取外掛', description: '列出已安裝的外掛和指令' },
},
};
+14 -11
View File
@@ -163,15 +163,19 @@ test('strict installation supports partial grants and a second confirmation', as
name: 'Permission confirmation required',
});
await expect(review).toBeVisible();
await review.locator('summary').click();
await expect(review.getByText('Dangerous', { exact: true })).toBeVisible();
await expect(
review.getByText('Sends bot messages', { exact: true }),
review.getByText('qa/permissions', { exact: true }),
).toBeVisible();
await review
.getByRole('checkbox', { name: 'sandbox', exact: true })
.uncheck();
await review
.getByRole('button', { name: 'Review permissions', exact: true })
.click();
await expect(page).toHaveURL(/id=qa%2Fpermissions/);
await expect(page.getByText('Dangerous', { exact: true })).toBeVisible();
await expect(
page.getByText('Sends bot messages', { exact: true }),
).toBeVisible();
await page.getByRole('checkbox', { name: /\(sandbox\)/ }).uncheck();
await page
.getByRole('button', { name: 'Review permissions', exact: true })
.click();
expect(writes).toHaveLength(0);
@@ -196,6 +200,7 @@ test('strict installation supports partial grants and a second confirmation', as
capabilities: ['bot.message.send'],
},
]);
await page.goto('/home/extensions');
await expect(review).toHaveCount(0);
});
@@ -219,9 +224,7 @@ test('compatibility confirmation grants all 16 capabilities and keeps its warnin
await expect.poll(() => 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 }),
+10 -4
View File
@@ -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,
);