mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
feat: config permissions RPC + UI tab + contact search improvements
Backend: - Add config.permissions.list/grant/revoke RPC methods - Auto-fill granted_by from caller's context identity - Fix Telegram contact display_name: FirstName + LastName (was FirstName only) - Change contact search to prefix match (term%) for B-tree index usage Frontend: - Add Permissions tab to agent detail with inline add row layout - Contact search combobox shows name, @username, sender_id, [channel] - Reduce contact search debounce from 300ms to 150ms - i18n keys for en/vi/zh
This commit is contained in:
1 parent
b6ff57ddab
commit
68a4ee39b4
14 files changed
+462
-8
No files matched your search
+1
-1
@@ -408,7 +408,7 @@ func runGateway() {
|
||||
|
||||
// Register all RPC methods
|
||||
server.SetLogTee(logTee)
|
||||
pairingMethods, heartbeatMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats)
|
||||
pairingMethods, heartbeatMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions)
|
||||
|
||||
// Wire pairing event broadcasts to all WS clients.
|
||||
pairingMethods.SetBroadcaster(server.BroadcastEvent)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore) (*methods.PairingMethods, *methods.HeartbeatMethods) {
|
||||
func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore) (*methods.PairingMethods, *methods.HeartbeatMethods) {
|
||||
router := server.Router()
|
||||
|
||||
// Phase 1: Core methods
|
||||
@@ -31,6 +31,9 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore
|
||||
heartbeatMethods := methods.NewHeartbeatMethods(heartbeatStore, msgBus)
|
||||
heartbeatMethods.Register(router)
|
||||
|
||||
// Phase 2: Config permissions
|
||||
methods.NewConfigPermissionsMethods(configPermStore, agentStore).Register(router)
|
||||
|
||||
// Phase 2: Pairing (store created externally, shared with channel manager).
|
||||
// OnApprove callback is set later by the caller after channel manager is created.
|
||||
pairingMethods := methods.NewPairingMethods(pairingStore, msgBus, server.RateLimiter())
|
||||
|
||||
@@ -280,7 +280,8 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
|
||||
|
||||
// Collect contact even when bot is not mentioned (cache prevents DB spam).
|
||||
if cc := c.ContactCollector(); cc != nil {
|
||||
cc.EnsureContact(ctx, c.Type(), c.Name(), userID, userID, user.FirstName, user.Username, "group")
|
||||
contactName := strings.TrimSpace(user.FirstName + " " + user.LastName)
|
||||
cc.EnsureContact(ctx, c.Type(), c.Name(), userID, userID, contactName, user.Username, "group")
|
||||
}
|
||||
|
||||
slog.Debug("telegram group message recorded (no mention)",
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package methods
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// ConfigPermissionsMethods handles config.permissions.* RPC methods.
|
||||
type ConfigPermissionsMethods struct {
|
||||
permStore store.ConfigPermissionStore
|
||||
agentStore store.AgentStore
|
||||
}
|
||||
|
||||
func NewConfigPermissionsMethods(ps store.ConfigPermissionStore, as store.AgentStore) *ConfigPermissionsMethods {
|
||||
return &ConfigPermissionsMethods{permStore: ps, agentStore: as}
|
||||
}
|
||||
|
||||
func (m *ConfigPermissionsMethods) Register(router *gateway.MethodRouter) {
|
||||
router.Register(protocol.MethodConfigPermissionsList, m.handleList)
|
||||
router.Register(protocol.MethodConfigPermissionsGrant, m.handleGrant)
|
||||
router.Register(protocol.MethodConfigPermissionsRevoke, m.handleRevoke)
|
||||
}
|
||||
|
||||
func (m *ConfigPermissionsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
var params struct {
|
||||
AgentID string `json:"agentId"`
|
||||
ConfigType string `json:"configType"`
|
||||
}
|
||||
if req.Params != nil {
|
||||
json.Unmarshal(req.Params, ¶ms)
|
||||
}
|
||||
if params.AgentID == "" {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "agentId")))
|
||||
return
|
||||
}
|
||||
|
||||
agentUUID, err := uuid.Parse(params.AgentID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid agentId"))
|
||||
return
|
||||
}
|
||||
|
||||
perms, err := m.permStore.List(ctx, agentUUID, params.ConfigType)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, configPermInternalErr("list", err)))
|
||||
return
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"permissions": perms}))
|
||||
}
|
||||
|
||||
func (m *ConfigPermissionsMethods) handleGrant(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
var params struct {
|
||||
AgentID string `json:"agentId"`
|
||||
Scope string `json:"scope"`
|
||||
ConfigType string `json:"configType"`
|
||||
UserID string `json:"userId"`
|
||||
Permission string `json:"permission"`
|
||||
GrantedBy *string `json:"grantedBy,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
}
|
||||
if req.Params != nil {
|
||||
json.Unmarshal(req.Params, ¶ms)
|
||||
}
|
||||
|
||||
switch {
|
||||
case params.AgentID == "":
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "agentId")))
|
||||
return
|
||||
case params.Scope == "":
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "scope")))
|
||||
return
|
||||
case params.ConfigType == "":
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "configType")))
|
||||
return
|
||||
case params.UserID == "":
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "userId")))
|
||||
return
|
||||
case params.Permission == "":
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "permission")))
|
||||
return
|
||||
}
|
||||
|
||||
agentUUID, err := uuid.Parse(params.AgentID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid agentId"))
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-fill grantedBy from the caller's identity if not explicitly provided.
|
||||
grantedBy := params.GrantedBy
|
||||
if grantedBy == nil {
|
||||
if caller := store.UserIDFromContext(ctx); caller != "" {
|
||||
grantedBy = &caller
|
||||
}
|
||||
}
|
||||
|
||||
perm := &store.ConfigPermission{
|
||||
AgentID: agentUUID,
|
||||
Scope: params.Scope,
|
||||
ConfigType: params.ConfigType,
|
||||
UserID: params.UserID,
|
||||
Permission: params.Permission,
|
||||
GrantedBy: grantedBy,
|
||||
Metadata: params.Metadata,
|
||||
}
|
||||
|
||||
if err := m.permStore.Grant(ctx, perm); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, configPermInternalErr("grant", err)))
|
||||
return
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true}))
|
||||
}
|
||||
|
||||
func (m *ConfigPermissionsMethods) handleRevoke(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
var params struct {
|
||||
AgentID string `json:"agentId"`
|
||||
Scope string `json:"scope"`
|
||||
ConfigType string `json:"configType"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if req.Params != nil {
|
||||
json.Unmarshal(req.Params, ¶ms)
|
||||
}
|
||||
|
||||
switch {
|
||||
case params.AgentID == "":
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "agentId")))
|
||||
return
|
||||
case params.Scope == "":
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "scope")))
|
||||
return
|
||||
case params.ConfigType == "":
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "configType")))
|
||||
return
|
||||
case params.UserID == "":
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "userId")))
|
||||
return
|
||||
}
|
||||
|
||||
agentUUID, err := uuid.Parse(params.AgentID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid agentId"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.permStore.Revoke(ctx, agentUUID, params.Scope, params.ConfigType, params.UserID); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, configPermInternalErr("revoke", err)))
|
||||
return
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true}))
|
||||
}
|
||||
|
||||
func configPermInternalErr(action string, err error) string {
|
||||
slog.Error("config.permissions RPC error", "action", action, "error", err)
|
||||
return "internal error"
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func contactWhereClause(opts store.ContactListOpts) (string, []any, int) {
|
||||
}
|
||||
if opts.Search != "" {
|
||||
escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search)
|
||||
pattern := "%" + escaped + "%"
|
||||
pattern := escaped + "%"
|
||||
conditions = append(conditions, fmt.Sprintf(
|
||||
"(display_name ILIKE $%d ESCAPE '\\' OR username ILIKE $%d ESCAPE '\\' OR sender_id ILIKE $%d ESCAPE '\\')",
|
||||
argIdx, argIdx, argIdx,
|
||||
|
||||
@@ -95,6 +95,13 @@ const (
|
||||
MethodHeartbeatTargets = "heartbeat.targets"
|
||||
)
|
||||
|
||||
// Config permissions
|
||||
const (
|
||||
MethodConfigPermissionsList = "config.permissions.list"
|
||||
MethodConfigPermissionsGrant = "config.permissions.grant"
|
||||
MethodConfigPermissionsRevoke = "config.permissions.revoke"
|
||||
)
|
||||
|
||||
// Channel instances management
|
||||
const (
|
||||
MethodChannelInstancesList = "channels.instances.list"
|
||||
|
||||
@@ -162,6 +162,11 @@ export const Methods = {
|
||||
HEARTBEAT_CHECKLIST_SET: "heartbeat.checklist.set",
|
||||
HEARTBEAT_TARGETS: "heartbeat.targets",
|
||||
|
||||
// Config permissions
|
||||
CONFIG_PERMISSIONS_LIST: "config.permissions.list",
|
||||
CONFIG_PERMISSIONS_GRANT: "config.permissions.grant",
|
||||
CONFIG_PERMISSIONS_REVOKE: "config.permissions.revoke",
|
||||
|
||||
// Phase 3+ - NICE TO HAVE
|
||||
LOGS_TAIL: "logs.tail",
|
||||
} as const;
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"shares": "Shares",
|
||||
"links": "Links",
|
||||
"skills": "Skills",
|
||||
"instances": "Instances"
|
||||
"instances": "Instances",
|
||||
"permissions": "Permissions"
|
||||
},
|
||||
"summonFailed": "Summon Failed",
|
||||
"evolving": "Evolving",
|
||||
@@ -468,6 +469,18 @@
|
||||
"noLogs": "No heartbeat logs yet.",
|
||||
"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.",
|
||||
"addRule": "Add Rule",
|
||||
"userIdPlaceholder": "Search contacts or type ID..."
|
||||
},
|
||||
"toast": {
|
||||
"created": "Agent created",
|
||||
"createFailed": "Failed to create agent",
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"shares": "Chia sẻ",
|
||||
"links": "Liên kết",
|
||||
"skills": "Skill",
|
||||
"instances": "Phiên bản người dùng"
|
||||
"instances": "Phiên bản người dùng",
|
||||
"permissions": "Phân quyền"
|
||||
},
|
||||
"summonFailed": "Triệu hồi thất bại",
|
||||
"evolving": "Đang tiến hóa",
|
||||
@@ -468,6 +469,18 @@
|
||||
"noLogs": "Chưa có nhật ký heartbeat.",
|
||||
"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.",
|
||||
"addRule": "Thêm quy tắc",
|
||||
"userIdPlaceholder": "Tìm liên hệ hoặc nhập ID..."
|
||||
},
|
||||
"toast": {
|
||||
"created": "Đã tạo agent",
|
||||
"createFailed": "Không thể tạo agent",
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"shares": "共享",
|
||||
"links": "链接",
|
||||
"skills": "Skill",
|
||||
"instances": "用户实例"
|
||||
"instances": "用户实例",
|
||||
"permissions": "权限"
|
||||
},
|
||||
"summonFailed": "召唤失败",
|
||||
"evolving": "进化中",
|
||||
@@ -468,6 +469,18 @@
|
||||
"noLogs": "暂无心跳日志。",
|
||||
"logsPagination": "{{from}}–{{to}} / 共 {{total}}"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "配置权限",
|
||||
"description": "控制谁可以通过聊天修改心跳、定时任务和上下文文件。",
|
||||
"scope": "范围",
|
||||
"configType": "配置类型",
|
||||
"userId": "用户 ID",
|
||||
"permission": "权限",
|
||||
"allTypes": "全部 (*)",
|
||||
"empty": "未配置权限规则。代理所有者默认拥有完全权限。",
|
||||
"addRule": "添加规则",
|
||||
"userIdPlaceholder": "搜索联系人或输入 ID..."
|
||||
},
|
||||
"toast": {
|
||||
"created": "代理已创建",
|
||||
"createFailed": "创建代理失败",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AgentHeader } from "./agent-header";
|
||||
import { AgentOverviewTab } from "./agent-overview-tab";
|
||||
import { AgentFilesTab } from "./agent-files-tab";
|
||||
import { AgentInstancesTab } from "./agent-instances-tab";
|
||||
import { AgentPermissionsTab } from "./agent-permissions-tab";
|
||||
import { AgentAdvancedDialog } from "./agent-advanced-dialog";
|
||||
import { HeartbeatConfigDialog } from "./heartbeat-config-dialog";
|
||||
import { SummoningModal } from "../summoning-modal";
|
||||
@@ -70,6 +71,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
<TabsList className="w-full justify-start overflow-x-auto overflow-y-hidden">
|
||||
<TabsTrigger value="agent">{t("detail.tabs.agent")}</TabsTrigger>
|
||||
<TabsTrigger value="files">{t("detail.tabs.files")}</TabsTrigger>
|
||||
<TabsTrigger value="permissions">{t("detail.tabs.permissions")}</TabsTrigger>
|
||||
{agent.agent_type === "predefined" && (
|
||||
<TabsTrigger value="instances">{t("detail.tabs.instances")}</TabsTrigger>
|
||||
)}
|
||||
@@ -90,6 +92,10 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="permissions" className="mt-4">
|
||||
<AgentPermissionsTab agentId={agentId} />
|
||||
</TabsContent>
|
||||
|
||||
{agent.agent_type === "predefined" && (
|
||||
<TabsContent value="instances" className="mt-4">
|
||||
<AgentInstancesTab agentId={agentId} />
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Plus, Trash2, Loader2, Shield } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { useContactSearch } from "../hooks/use-contact-search";
|
||||
|
||||
const CONFIG_TYPES = [
|
||||
{ value: "heartbeat", label: "Heartbeat" },
|
||||
{ value: "cron", label: "Cron" },
|
||||
{ value: "context_files", label: "Context Files" },
|
||||
{ value: "*", label: "All (*)" },
|
||||
] as const;
|
||||
|
||||
const SCOPES = [
|
||||
{ value: "agent", label: "Agent" },
|
||||
{ value: "*", label: "Global (*)" },
|
||||
] as const;
|
||||
|
||||
interface AgentPermissionsTabProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const { permissions, loading, load, grant, revoke } = useConfigPermissions(agentId);
|
||||
|
||||
const [userId, setUserId] = useState("");
|
||||
const [configType, setConfigType] = useState("heartbeat");
|
||||
const [scope, setScope] = useState("agent");
|
||||
const [permission, setPermission] = useState("allow");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
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}`,
|
||||
};
|
||||
}),
|
||||
[contacts],
|
||||
);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!userId.trim()) return;
|
||||
setAdding(true);
|
||||
await grant(scope, configType, userId.trim(), permission);
|
||||
setUserId("");
|
||||
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>
|
||||
|
||||
{/* 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" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Rules list */}
|
||||
{loading ? (
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import { useWs } from "@/hooks/use-ws";
|
||||
import { Methods } from "@/api/protocol";
|
||||
|
||||
export interface ConfigPermission {
|
||||
id: string;
|
||||
agentId: string;
|
||||
scope: string;
|
||||
configType: string;
|
||||
userId: string;
|
||||
permission: string; // "allow" | "deny"
|
||||
grantedBy?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function useConfigPermissions(agentId: string | undefined) {
|
||||
const ws = useWs();
|
||||
const [permissions, setPermissions] = useState<ConfigPermission[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!agentId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await ws.call<{ permissions: ConfigPermission[] }>(
|
||||
Methods.CONFIG_PERMISSIONS_LIST,
|
||||
{ agentId },
|
||||
);
|
||||
setPermissions(res.permissions ?? []);
|
||||
} catch {
|
||||
// silent — permissions may not be available for all users
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [ws, agentId]);
|
||||
|
||||
const grant = useCallback(
|
||||
async (scope: string, configType: string, userId: string, permission: string) => {
|
||||
if (!agentId) return;
|
||||
try {
|
||||
await ws.call(Methods.CONFIG_PERMISSIONS_GRANT, {
|
||||
agentId, scope, configType, userId, permission,
|
||||
});
|
||||
toast.success("Permission granted");
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to grant permission");
|
||||
}
|
||||
},
|
||||
[ws, agentId, load],
|
||||
);
|
||||
|
||||
const revoke = useCallback(
|
||||
async (scope: string, configType: string, userId: string) => {
|
||||
if (!agentId) return;
|
||||
try {
|
||||
await ws.call(Methods.CONFIG_PERMISSIONS_REVOKE, {
|
||||
agentId, scope, configType, userId,
|
||||
});
|
||||
toast.success("Permission revoked");
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to revoke permission");
|
||||
}
|
||||
},
|
||||
[ws, agentId, load],
|
||||
);
|
||||
|
||||
return { permissions, loading, load, grant, revoke };
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export function useContactSearch(search: string) {
|
||||
|
||||
// Simple debounce via timeout
|
||||
useMemo(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 150);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user