From 68a4ee39b46ee3a3affa33c7711c7aacf08da7c7 Mon Sep 17 00:00:00 2001 From: viettranx Date: Thu, 19 Mar 2026 11:24:28 +0700 Subject: [PATCH] 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 --- cmd/gateway.go | 2 +- cmd/gateway_methods.go | 5 +- internal/channels/telegram/handlers.go | 3 +- .../gateway/methods/config_permissions.go | 169 ++++++++++++++++++ internal/store/pg/channel_contacts.go | 2 +- pkg/protocol/methods.go | 7 + ui/web/src/api/protocol.ts | 5 + ui/web/src/i18n/locales/en/agents.json | 15 +- ui/web/src/i18n/locales/vi/agents.json | 15 +- ui/web/src/i18n/locales/zh/agents.json | 15 +- .../agents/agent-detail/agent-detail-page.tsx | 6 + .../agent-detail/agent-permissions-tab.tsx | 152 ++++++++++++++++ .../agents/hooks/use-config-permissions.ts | 72 ++++++++ .../pages/agents/hooks/use-contact-search.ts | 2 +- 14 files changed, 462 insertions(+), 8 deletions(-) create mode 100644 internal/gateway/methods/config_permissions.go create mode 100644 ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx create mode 100644 ui/web/src/pages/agents/hooks/use-config-permissions.ts diff --git a/cmd/gateway.go b/cmd/gateway.go index ea8d0aa3..efbcb68d 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -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) diff --git a/cmd/gateway_methods.go b/cmd/gateway_methods.go index 9dab82b7..323d9b92 100644 --- a/cmd/gateway_methods.go +++ b/cmd/gateway_methods.go @@ -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()) diff --git a/internal/channels/telegram/handlers.go b/internal/channels/telegram/handlers.go index a36fb082..ebd6c5ff 100644 --- a/internal/channels/telegram/handlers.go +++ b/internal/channels/telegram/handlers.go @@ -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)", diff --git a/internal/gateway/methods/config_permissions.go b/internal/gateway/methods/config_permissions.go new file mode 100644 index 00000000..db20ca24 --- /dev/null +++ b/internal/gateway/methods/config_permissions.go @@ -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" +} diff --git a/internal/store/pg/channel_contacts.go b/internal/store/pg/channel_contacts.go index 72f69e2d..d63a4ada 100644 --- a/internal/store/pg/channel_contacts.go +++ b/internal/store/pg/channel_contacts.go @@ -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, diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 06fe513d..a26bb693 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -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" diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index 3f15866e..a0f79412 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -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; diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index f05921d1..0168c2b2 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -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", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index 27af4c68..7bcacc08 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -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", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index ff99b235..fef1a2a5 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -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": "创建代理失败", diff --git a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx index b41ebf84..176b1f8f 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx @@ -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) { {t("detail.tabs.agent")} {t("detail.tabs.files")} + {t("detail.tabs.permissions")} {agent.agent_type === "predefined" && ( {t("detail.tabs.instances")} )} @@ -90,6 +92,10 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { /> + + + + {agent.agent_type === "predefined" && ( diff --git a/ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx new file mode 100644 index 00000000..8c9fe3f8 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx @@ -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 ( +
+
+

+ + {t("permissions.title")} +

+

{t("permissions.description")}

+
+ + {/* Inline add row */} +
+ + + + + +
+ + {/* Rules list */} + {loading ? ( +
+ +
+ ) : permissions.length === 0 ? ( +

{t("permissions.empty")}

+ ) : ( +
+ {permissions.map((p) => ( +
+
+ + {p.permission} + + {p.userId} + {p.configType} + @ {p.scope} +
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/ui/web/src/pages/agents/hooks/use-config-permissions.ts b/ui/web/src/pages/agents/hooks/use-config-permissions.ts new file mode 100644 index 00000000..2adb09fa --- /dev/null +++ b/ui/web/src/pages/agents/hooks/use-config-permissions.ts @@ -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([]); + 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 }; +} diff --git a/ui/web/src/pages/agents/hooks/use-contact-search.ts b/ui/web/src/pages/agents/hooks/use-contact-search.ts index 2b7c8cbc..dfb34c9b 100644 --- a/ui/web/src/pages/agents/hooks/use-contact-search.ts +++ b/ui/web/src/pages/agents/hooks/use-contact-search.ts @@ -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]);