mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-03 16:16:44 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user