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:
viettranx committed 2026-03-19 13:35:57 +07:00
1 parent b6ff57ddab
commit 68a4ee39b4
14 files changed
+462 -8

No files matched your search

+1 -1
View File
@@ -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)
+4 -1
View File
@@ -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())
+2 -1
View File
@@ -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, &params)
}
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, &params)
}
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, &params)
}
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"
}
+1 -1
View File
@@ -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,
+7
View File
@@ -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"
+5
View File
@@ -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;
+14 -1
View File
@@ -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",
+14 -1
View File
@@ -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",
+14 -1
View File
@@ -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]);