feat(ui): redesign permissions tab with file writer support

- Add file_writer to CONFIG_TYPES with contextual descriptions
- Smart scope select: dynamic group scopes from existing rules + Combobox
  for custom group IDs (e.g. group:telegram:-100456)
- Auto-fill metadata (displayName/username) from contact selection
- Two-section display: File Writers grouped by scope, Config Permissions flat
- Add metadata field to ConfigPermission interface + grant()
- i18n: permission type descriptions + scope labels in 3 locales
This commit is contained in:
viettranx
2026-03-19 13:35:57 +07:00
parent 6595c0185a
commit 8a63e38ecd
5 changed files with 319 additions and 120 deletions
+25 -9
View File
@@ -40,6 +40,7 @@
"delete": {
"title": "Delete Agent",
"description": "Permanently delete this agent, all its context files, sessions, and configuration. This action cannot be undone.",
"deleteWarning": "This will permanently delete the agent and ALL related data including: sessions, messages, cron jobs, heartbeats, memories, knowledge graph, skills, channel configurations, teams, workspace files, and permissions. This action cannot be undone.",
"detailDescription": "Are you sure you want to delete \"{{name}}\"? All context files, sessions, and configuration will be permanently removed.",
"confirmLabel": "Delete",
"defaultCannotDelete": "The default agent cannot be deleted."
@@ -470,16 +471,31 @@
"logsPagination": "{{from}}{{to}} of {{total}}"
},
"permissions": {
"title": "Config Permissions",
"description": "Control who can modify heartbeat, cron, and context files via chat.",
"scope": "Scope",
"configType": "Config Type",
"userId": "User ID",
"permission": "Permission",
"allTypes": "All (*)",
"empty": "No permission rules configured. Agent owner has full access by default.",
"title": "Permissions",
"description": "Control who can modify agent config and files. Owner always has full access.",
"addRule": "Add Rule",
"userIdPlaceholder": "Search contacts or type ID..."
"fileWriters": "File Writers",
"configPerms": "Config Permissions",
"noRules": "No permission rules. Owner has implicit full access.",
"userIdPlaceholder": "Search contacts or type ID...",
"scopePlaceholder": "Select or type scope...",
"types": {
"file_writer": "File Writer",
"file_writer_desc": "Controls who can edit files and manage cron in group chats. First user auto-added.",
"heartbeat": "Heartbeat",
"heartbeat_desc": "Controls who can configure heartbeat schedule and settings via chat.",
"cron": "Cron",
"cron_desc": "Controls who can create and manage scheduled cron jobs via chat.",
"context_files": "Context Files",
"context_files_desc": "Controls who can modify agent identity files (SOUL.md, etc.) via chat.",
"all": "All (*)",
"all_desc": "Full access to all configuration types."
},
"scopes": {
"agent": "Agent (DM only)",
"group_all": "All Groups",
"global": "Global (all contexts)"
}
},
"toast": {
"created": "Agent created",
+25 -9
View File
@@ -40,6 +40,7 @@
"delete": {
"title": "Xóa agent",
"description": "Xóa vĩnh viễn agent này, tất cả tệp ngữ cảnh, session và cấu hình. Hành động này không thể hoàn tác.",
"deleteWarning": "Thao tác này sẽ xóa vĩnh viễn agent và TẤT CẢ dữ liệu liên quan bao gồm: phiên chat, tin nhắn, cron job, heartbeat, bộ nhớ, đồ thị tri thức, kỹ năng, cấu hình kênh, đội nhóm, file workspace và quyền hạn. Không thể hoàn tác.",
"detailDescription": "Bạn có chắc chắn muốn xóa \"{{name}}\"? Tất cả tệp ngữ cảnh, session và cấu hình sẽ bị xóa vĩnh viễn.",
"confirmLabel": "Xóa",
"defaultCannotDelete": "Agent mặc định không thể bị xóa."
@@ -470,16 +471,31 @@
"logsPagination": "{{from}}{{to}} / {{total}}"
},
"permissions": {
"title": "Phân quyền cấu hình",
"description": "Quản lý ai có thể thay đổi heartbeat, cron, context files qua chat.",
"scope": "Phạm vi",
"configType": "Loại cấu hình",
"userId": "ID người dùng",
"permission": "Quyền",
"allTypes": "Tất cả (*)",
"empty": "Chưa có quy tắc phân quyền. Chủ sở hữu agent có toàn quyền mặc định.",
"title": "Quyền hạn",
"description": "Quản lý ai được phép thay đổi cấu hình agent và file. Chủ sở hữu luôn có quyền đầy đủ.",
"addRule": "Thêm quy tắc",
"userIdPlaceholder": "Tìm liên hệ hoặc nhập ID..."
"fileWriters": "Người viết file",
"configPerms": "Quyền cấu hình",
"noRules": "Chưa có quy tắc. Chủ sở hữu mặc định có đầy đủ quyền.",
"userIdPlaceholder": "Tìm liên hệ hoặc nhập ID...",
"scopePlaceholder": "Chọn hoặc nhập phạm vi...",
"types": {
"file_writer": "Quyền viết file",
"file_writer_desc": "Ai được chỉnh sửa file và quản lý cron trong nhóm chat. Người đầu tiên tự động được thêm.",
"heartbeat": "Heartbeat",
"heartbeat_desc": "Ai được cấu hình lịch heartbeat qua chat.",
"cron": "Cron",
"cron_desc": "Ai được tạo và quản lý cron job qua chat.",
"context_files": "File ngữ cảnh",
"context_files_desc": "Ai được chỉnh sửa file nhận dạng agent (SOUL.md, v.v.) qua chat.",
"all": "Tất cả (*)",
"all_desc": "Toàn quyền cho mọi loại cấu hình."
},
"scopes": {
"agent": "Agent (riêng tư)",
"group_all": "Tất cả nhóm",
"global": "Toàn cục"
}
},
"toast": {
"created": "Đã tạo agent",
+25 -9
View File
@@ -40,6 +40,7 @@
"delete": {
"title": "删除Agent",
"description": "永久删除此Agent及其所有上下文文件、Session和配置。此操作无法撤销。",
"deleteWarning": "此操作将永久删除代理及所有相关数据,包括:会话、消息、定时任务、心跳检测、记忆、知识图谱、技能、渠道配置、团队、工作区文件和权限。此操作不可撤销。",
"detailDescription": "确定要删除「{{name}}」吗?所有上下文文件、Session和配置将被永久删除。",
"confirmLabel": "删除",
"defaultCannotDelete": "默认Agent无法删除。"
@@ -470,16 +471,31 @@
"logsPagination": "{{from}}{{to}} / 共 {{total}}"
},
"permissions": {
"title": "配置权限",
"description": "控制谁可以通过聊天修改心跳、定时任务和上下文文件。",
"scope": "范围",
"configType": "配置类型",
"userId": "用户 ID",
"permission": "权限",
"allTypes": "全部 (*)",
"empty": "未配置权限规则。代理所有者默认拥有完全权限。",
"title": "权限管理",
"description": "控制谁可以修改代理配置和文件。所有者始终拥有完全访问权限。",
"addRule": "添加规则",
"userIdPlaceholder": "搜索联系人或输入 ID..."
"fileWriters": "文件编辑者",
"configPerms": "配置权限",
"noRules": "暂无权限规则。所有者默认拥有完全访问权限。",
"userIdPlaceholder": "搜索联系人或输入ID...",
"scopePlaceholder": "选择或输入范围...",
"types": {
"file_writer": "文件编辑",
"file_writer_desc": "控制谁可以在群聊中编辑文件和管理定时任务。首位用户自动添加。",
"heartbeat": "心跳检测",
"heartbeat_desc": "控制谁可以通过聊天配置心跳检测计划和设置。",
"cron": "定时任务",
"cron_desc": "控制谁可以通过聊天创建和管理定时任务。",
"context_files": "上下文文件",
"context_files_desc": "控制谁可以通过聊天修改代理身份文件(SOUL.md等)。",
"all": "全部 (*)",
"all_desc": "所有配置类型的完全访问权限。"
},
"scopes": {
"agent": "代理(私聊)",
"group_all": "所有群组",
"global": "全局"
}
},
"toast": {
"created": "代理已创建",
@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from "react";
import { Plus, Trash2, Loader2, Shield } from "lucide-react";
import { Plus, Trash2, Loader2, Shield, FolderOpen, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -7,20 +7,33 @@ import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useConfigPermissions } from "../hooks/use-config-permissions";
import { useConfigPermissions, type ConfigPermission } from "../hooks/use-config-permissions";
import { useContactSearch } from "../hooks/use-contact-search";
import type { ChannelContact } from "@/types/contact";
const CONFIG_TYPES = [
{ value: "heartbeat", label: "Heartbeat" },
{ value: "cron", label: "Cron" },
{ value: "context_files", label: "Context Files" },
{ value: "*", label: "All (*)" },
] as const;
{ value: "file_writer", label: "File Writer", descKey: "permissions.types.file_writer_desc" },
{ value: "heartbeat", label: "Heartbeat", descKey: "permissions.types.heartbeat_desc" },
{ value: "cron", label: "Cron", descKey: "permissions.types.cron_desc" },
{ value: "context_files", label: "Context Files", descKey: "permissions.types.context_files_desc" },
{ value: "*", label: "All (*)", descKey: "permissions.types.all_desc" },
];
const SCOPES = [
{ value: "agent", label: "Agent" },
{ value: "*", label: "Global (*)" },
] as const;
function getScopeOptions(configType: string, existingScopes: string[]): ComboboxOption[] {
if (configType === "file_writer") {
const dynamic = existingScopes.map((s) => ({ value: s, label: s }));
return [
{ value: "group:*", label: "All Groups" },
...dynamic,
{ value: "*", label: "Global (*)" },
];
}
return [
{ value: "agent", label: "Agent (DM)" },
{ value: "group:*", label: "All Groups" },
{ value: "*", label: "Global (*)" },
];
}
interface AgentPermissionsTabProps {
agentId: string;
@@ -31,120 +44,257 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) {
const { permissions, loading, load, grant, revoke } = useConfigPermissions(agentId);
const [userId, setUserId] = useState("");
const [configType, setConfigType] = useState("heartbeat");
const [scope, setScope] = useState("agent");
const [configType, setConfigType] = useState("file_writer");
const [scope, setScope] = useState("group:*");
const [permission, setPermission] = useState("allow");
const [adding, setAdding] = useState(false);
const [selectedContact, setSelectedContact] = useState<ChannelContact | null>(null);
const { contacts } = useContactSearch(userId);
const contactOptions: ComboboxOption[] = useMemo(() =>
contacts.map((c) => {
const name = c.display_name || c.sender_id;
const username = c.username ? ` @${c.username}` : "";
const channel = c.channel_type ? ` [${c.channel_type}]` : "";
return {
value: c.sender_id,
label: `${name}${username} (${c.sender_id})${channel}`,
};
return { value: c.sender_id, label: `${name}${username} (${c.sender_id})${channel}` };
}),
[contacts],
);
// Collect existing file_writer scopes for dynamic scope options
const existingFileWriterScopes = useMemo(() =>
[...new Set(
permissions
.filter((p) => p.configType === "file_writer")
.map((p) => p.scope)
)],
[permissions],
);
const scopeOptions = useMemo(
() => getScopeOptions(configType, existingFileWriterScopes),
[configType, existingFileWriterScopes],
);
// Reset scope when configType changes
useEffect(() => {
if (configType === "file_writer") {
setScope("group:*");
} else {
setScope("agent");
}
}, [configType]);
useEffect(() => { load(); }, [load]);
const handleUserChange = (val: string) => {
setUserId(val);
const contact = contacts.find((c) => c.sender_id === val);
setSelectedContact(contact ?? null);
};
const handleAdd = async () => {
if (!userId.trim()) return;
setAdding(true);
await grant(scope, configType, userId.trim(), permission);
const meta =
configType === "file_writer" && selectedContact
? {
displayName: selectedContact.display_name ?? "",
username: selectedContact.username ?? "",
}
: undefined;
await grant(scope, configType, userId.trim(), permission, meta);
setUserId("");
setSelectedContact(null);
setAdding(false);
};
return (
<div className="space-y-3">
<div>
<h3 className="text-sm font-medium flex items-center gap-2">
<Shield className="h-4 w-4 text-amber-500" />
{t("permissions.title")}
</h3>
<p className="text-xs text-muted-foreground mt-1">{t("permissions.description")}</p>
</div>
// Split permissions into two sections
const fileWriters = useMemo(
() => permissions.filter((p) => p.configType === "file_writer"),
[permissions],
);
const configPerms = useMemo(
() => permissions.filter((p) => p.configType !== "file_writer"),
[permissions],
);
{/* Inline add row */}
<div className="flex flex-wrap items-end gap-2">
<Combobox
value={userId}
onChange={setUserId}
options={contactOptions}
placeholder={t("permissions.userIdPlaceholder")}
className="flex-1 min-w-[140px]"
/>
<Select value={configType} onValueChange={setConfigType}>
<SelectTrigger className="w-[120px] text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONFIG_TYPES.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={scope} onValueChange={setScope}>
<SelectTrigger className="w-[100px] text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPES.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={permission} onValueChange={setPermission}>
<SelectTrigger className="w-[90px] text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="allow">Allow</SelectItem>
<SelectItem value="deny">Deny</SelectItem>
</SelectContent>
</Select>
<Button size="icon" className="h-9 w-9 shrink-0" onClick={handleAdd} disabled={adding || !userId.trim()}>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
// Group file_writer by scope
const fileWritersByScope = useMemo(() => {
const map = new Map<string, ConfigPermission[]>();
for (const p of fileWriters) {
const list = map.get(p.scope) ?? [];
list.push(p);
map.set(p.scope, list);
}
return map;
}, [fileWriters]);
const currentDescKey = CONFIG_TYPES.find((c) => c.value === configType)?.descKey ?? "";
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-sm font-medium flex items-center gap-2">
<Shield className="h-4 w-4 text-amber-500" />
{t("permissions.title")}
</h3>
<p className="text-xs text-muted-foreground mt-1">{t("permissions.description")}</p>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0 text-muted-foreground"
onClick={load}
disabled={loading}
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
</Button>
</div>
{/* Add Rule form */}
<div className="space-y-2">
<div className="flex flex-wrap items-end gap-2">
<Combobox
value={userId}
onChange={handleUserChange}
options={contactOptions}
placeholder={t("permissions.userIdPlaceholder")}
className="flex-1 min-w-[160px]"
/>
<Select value={configType} onValueChange={setConfigType}>
<SelectTrigger className="w-[130px] text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONFIG_TYPES.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
value={scope}
onChange={setScope}
options={scopeOptions}
placeholder={t("permissions.scopePlaceholder")}
className="min-w-[140px]"
/>
<Select value={permission} onValueChange={setPermission}>
<SelectTrigger className="w-[90px] text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="allow">Allow</SelectItem>
<SelectItem value="deny">Deny</SelectItem>
</SelectContent>
</Select>
<Button
size="icon"
className="h-9 w-9 shrink-0"
onClick={handleAdd}
disabled={adding || !userId.trim()}
>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
</Button>
</div>
{currentDescKey && (
<p className="text-xs text-muted-foreground">{t(currentDescKey)}</p>
)}
</div>
{/* Rules list */}
{loading ? (
{loading && permissions.length === 0 ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : permissions.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-6">{t("permissions.empty")}</p>
<p className="text-xs text-muted-foreground text-center py-6">{t("permissions.noRules")}</p>
) : (
<div className="rounded-lg border divide-y">
{permissions.map((p) => (
<div key={p.id} className="flex items-center justify-between gap-2 px-3 py-2">
<div className="flex items-center gap-2 min-w-0 text-sm">
<Badge
variant={p.permission === "allow" ? "success" : "destructive"}
className="text-[10px] shrink-0"
>
{p.permission}
</Badge>
<span className="font-medium truncate">{p.userId}</span>
<span className="text-[11px] text-muted-foreground shrink-0">{p.configType}</span>
<span className="text-[11px] text-muted-foreground shrink-0">@ {p.scope}</span>
<div className="space-y-4">
{/* File Writers section */}
{fileWriters.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">
{t("permissions.fileWriters")} ({fileWriters.length})
</p>
<div className="rounded-lg border divide-y">
{[...fileWritersByScope.entries()].map(([scopeKey, writers]) => (
<div key={scopeKey}>
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-muted/40">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">{scopeKey}</span>
</div>
{writers.map((p) => {
const displayName = p.metadata?.displayName || p.userId;
const username = p.metadata?.username ? ` @${p.metadata.username}` : "";
return (
<div key={p.id} className="flex items-center justify-between gap-2 px-3 py-2 pl-7">
<div className="flex items-center gap-2 min-w-0 text-sm">
<Badge
variant={p.permission === "allow" ? "success" : "destructive"}
className="text-[10px] shrink-0"
>
{p.permission}
</Badge>
<span className="font-medium truncate">{displayName}</span>
{username && (
<span className="text-[11px] text-muted-foreground shrink-0">{username}</span>
)}
<span className="text-[11px] text-muted-foreground shrink-0 font-mono">({p.userId})</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => revoke(p.scope, p.configType, p.userId)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
);
})}
</div>
))}
</div>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => revoke(p.scope, p.configType, p.userId)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
)}
{/* Config Permissions section */}
{configPerms.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">
{t("permissions.configPerms")} ({configPerms.length})
</p>
<div className="rounded-lg border divide-y">
{configPerms.map((p) => (
<div key={p.id} className="flex items-center justify-between gap-2 px-3 py-2">
<div className="flex items-center gap-2 min-w-0 text-sm">
<Badge
variant={p.permission === "allow" ? "success" : "destructive"}
className="text-[10px] shrink-0"
>
{p.permission}
</Badge>
<span className="font-medium truncate">{p.userId}</span>
<span className="text-[11px] text-muted-foreground shrink-0">{p.configType}</span>
<span className="text-[11px] text-muted-foreground shrink-0">@ {p.scope}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => revoke(p.scope, p.configType, p.userId)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
@@ -11,6 +11,7 @@ export interface ConfigPermission {
userId: string;
permission: string; // "allow" | "deny"
grantedBy?: string;
metadata?: Record<string, string>; // {displayName, username}
createdAt: string;
updatedAt: string;
}
@@ -37,11 +38,11 @@ export function useConfigPermissions(agentId: string | undefined) {
}, [ws, agentId]);
const grant = useCallback(
async (scope: string, configType: string, userId: string, permission: string) => {
async (scope: string, configType: string, userId: string, permission: string, metadata?: Record<string, string>) => {
if (!agentId) return;
try {
await ws.call(Methods.CONFIG_PERMISSIONS_GRANT, {
agentId, scope, configType, userId, permission,
agentId, scope, configType, userId, permission, metadata,
});
toast.success("Permission granted");
await load();