mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 20:14:50 +00:00
fix(skills): per-tenant skill toggle for non-master tenants
Skills page toggle was calling global /toggle endpoint (master-tenant-only),
returning 403 when owner switched to another tenant. Now uses per-tenant
override endpoints (PUT/DELETE /v1/skills/{id}/tenant-config) when scoped
to non-master tenant, matching builtin-tools page pattern.
- Add tenant_enabled merging to WS skills.list handler
- Add setTenantConfig/deleteTenantConfig to use-skills hook
- Add SkillTenantOverride component with badge + switch + reset
- Fix wrong MasterTenantID constant in both skills and builtin-tools pages
- Add i18n keys for en/vi/zh
This commit is contained in:
1 parent
f94fc1528e
commit
31c41dac54
10 files changed
+181
-15
No files matched your search
+1
-1
@@ -481,7 +481,7 @@ func runGateway() {
|
||||
|
||||
// Register all RPC methods
|
||||
server.SetLogTee(logTee)
|
||||
pairingMethods, heartbeatMethods, chatMethods := 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, pgStores.SystemConfigs, pgStores.Tenants)
|
||||
pairingMethods, heartbeatMethods, chatMethods := 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, pgStores.SystemConfigs, pgStores.Tenants, pgStores.SkillTenantCfgs)
|
||||
|
||||
// Wire post-turn processor for team task dispatch (WS chat.send + HTTP API paths).
|
||||
if postTurn != nil {
|
||||
|
||||
@@ -13,7 +13,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, configPermStore store.ConfigPermissionStore, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods) {
|
||||
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, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore, skillTenantCfgStore store.SkillTenantConfigStore) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods) {
|
||||
router := server.Router()
|
||||
|
||||
// Phase 1: Core methods
|
||||
@@ -36,7 +36,7 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore
|
||||
configMethods.Register(router)
|
||||
|
||||
// Phase 2: Skills (uses SkillStore interface — PG or File)
|
||||
methods.NewSkillsMethods(skillStore).Register(router)
|
||||
methods.NewSkillsMethods(skillStore, skillTenantCfgStore).Register(router)
|
||||
|
||||
// Phase 2: Cron (store created externally, shared with gateway)
|
||||
methods.NewCronMethods(cronStore, msgBus, cfg).Register(router)
|
||||
|
||||
@@ -3,6 +3,7 @@ package methods
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -20,11 +21,12 @@ type skillOwnerGetter interface {
|
||||
|
||||
// SkillsMethods handles skills.list, skills.get, skills.update.
|
||||
type SkillsMethods struct {
|
||||
store store.SkillStore
|
||||
store store.SkillStore
|
||||
tenantCfgStore store.SkillTenantConfigStore
|
||||
}
|
||||
|
||||
func NewSkillsMethods(s store.SkillStore) *SkillsMethods {
|
||||
return &SkillsMethods{store: s}
|
||||
func NewSkillsMethods(s store.SkillStore, tenantCfg store.SkillTenantConfigStore) *SkillsMethods {
|
||||
return &SkillsMethods{store: s, tenantCfgStore: tenantCfg}
|
||||
}
|
||||
|
||||
func (m *SkillsMethods) Register(router *gateway.MethodRouter) {
|
||||
@@ -68,6 +70,28 @@ func (m *SkillsMethods) handleList(ctx context.Context, client *gateway.Client,
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
// Merge per-tenant overrides when tenant-scoped
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid != uuid.Nil && m.tenantCfgStore != nil {
|
||||
overrides, err := m.tenantCfgStore.ListAll(ctx, tid)
|
||||
if err != nil {
|
||||
slog.Warn("skill tenant config list failed", "tenant", tid, "error", err)
|
||||
}
|
||||
if err == nil && len(overrides) > 0 {
|
||||
for i, entry := range result {
|
||||
idStr, _ := entry["id"].(string)
|
||||
if idStr == "" {
|
||||
continue
|
||||
}
|
||||
if skID, err := uuid.Parse(idStr); err == nil {
|
||||
if enabled, ok := overrides[skID]; ok {
|
||||
result[i]["tenant_enabled"] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
"skills": result,
|
||||
}))
|
||||
|
||||
@@ -73,6 +73,13 @@
|
||||
"disable": "Disable skill",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"tenant": {
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"default": "Default",
|
||||
"overrideHint": "Per-tenant override for this skill",
|
||||
"resetDefault": "Reset to default"
|
||||
},
|
||||
"deps": {
|
||||
"rescan": "Rescan Deps",
|
||||
"rescanning": "Scanning...",
|
||||
|
||||
@@ -73,6 +73,13 @@
|
||||
"disable": "Tắt skill",
|
||||
"disabled": "Đã tắt"
|
||||
},
|
||||
"tenant": {
|
||||
"enabled": "Đã bật",
|
||||
"disabled": "Đã tắt",
|
||||
"default": "Mặc định",
|
||||
"overrideHint": "Ghi đè riêng cho tenant này",
|
||||
"resetDefault": "Đặt lại mặc định"
|
||||
},
|
||||
"deps": {
|
||||
"rescan": "Rescan Deps",
|
||||
"rescanning": "Scanning...",
|
||||
|
||||
@@ -73,6 +73,13 @@
|
||||
"disable": "禁用Skill",
|
||||
"disabled": "已禁用"
|
||||
},
|
||||
"tenant": {
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"default": "默认",
|
||||
"overrideHint": "此租户的技能覆盖设置",
|
||||
"resetDefault": "恢复默认"
|
||||
},
|
||||
"deps": {
|
||||
"rescan": "Rescan Deps",
|
||||
"rescanning": "Scanning...",
|
||||
|
||||
@@ -249,7 +249,7 @@ function ToolRow({
|
||||
const configHint = getConfigHint(tool);
|
||||
const editable = hasEditableSettings(tool);
|
||||
const deprecated = isDeprecated(tool);
|
||||
const hasTenantScope = !!tenantId && tenantId !== "00000000-0000-0000-0000-000000000000";
|
||||
const hasTenantScope = !!tenantId && tenantId !== "0193a5b0-7000-7000-8000-000000000001";
|
||||
const hasOverride = tool.tenant_enabled !== null && tool.tenant_enabled !== undefined;
|
||||
|
||||
return (
|
||||
|
||||
@@ -174,9 +174,38 @@ export function useSkills() {
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const setTenantConfig = useCallback(
|
||||
async (id: string, enabled: boolean) => {
|
||||
try {
|
||||
await http.put(`/v1/skills/${id}/tenant-config`, { enabled });
|
||||
await invalidate();
|
||||
toast.success(i18next.t("skills:toast.updated"));
|
||||
} catch (err) {
|
||||
toast.error(i18next.t("skills:toast.updateFailed"), userFriendlyError(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const deleteTenantConfig = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await http.delete(`/v1/skills/${id}/tenant-config`);
|
||||
await invalidate();
|
||||
toast.success(i18next.t("skills:toast.updated"));
|
||||
} catch (err) {
|
||||
toast.error(i18next.t("skills:toast.updateFailed"), userFriendlyError(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
return {
|
||||
skills, loading, refresh: invalidate, getSkill,
|
||||
uploadSkill, updateSkill, deleteSkill,
|
||||
getSkillVersions, getSkillFiles, getSkillFileContent, rescanDeps, installDeps, installSingleDep, toggleSkill,
|
||||
setTenantConfig, deleteTenantConfig,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Zap, Pencil, RefreshCw, Upload, Trash2, ScanSearch } from "lucide-react";
|
||||
import { Zap, Pencil, RefreshCw, Upload, Trash2, ScanSearch, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { SearchInput } from "@/components/shared/search-input";
|
||||
@@ -20,6 +21,9 @@ import { useRuntimes } from "./hooks/use-runtimes";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { usePagination } from "@/hooks/use-pagination";
|
||||
import { useTenants } from "@/hooks/use-tenants";
|
||||
|
||||
const MASTER_TENANT_ID = "0193a5b0-7000-7000-8000-000000000001";
|
||||
|
||||
const visibilityColor: Record<string, string> = {
|
||||
public: "default",
|
||||
@@ -34,8 +38,11 @@ export function SkillsPage() {
|
||||
const {
|
||||
skills, loading, refresh, getSkill, uploadSkill, updateSkill, deleteSkill,
|
||||
getSkillVersions, getSkillFiles, getSkillFileContent, rescanDeps, installSingleDep, toggleSkill,
|
||||
setTenantConfig, deleteTenantConfig,
|
||||
} = useSkills();
|
||||
const { runtimes } = useRuntimes();
|
||||
const { currentTenantId } = useTenants();
|
||||
const hasTenantScope = !!currentTenantId && currentTenantId !== MASTER_TENANT_ID;
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && skills.length === 0);
|
||||
const [tab, setTab] = useState<Tab>("core");
|
||||
@@ -286,13 +293,28 @@ export function SkillsPage() {
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{skill.id && (
|
||||
<>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={skill.enabled !== false}
|
||||
disabled={toggling === skill.id}
|
||||
onCheckedChange={(checked) => handleToggle(skill, checked)}
|
||||
title={skill.enabled !== false ? t("toggle.disable") : t("toggle.enable")}
|
||||
/>
|
||||
{hasTenantScope ? (
|
||||
<SkillTenantOverride
|
||||
skill={skill}
|
||||
toggling={toggling === skill.id}
|
||||
onSetTenantConfig={async (id, enabled) => {
|
||||
setToggling(id);
|
||||
try { await setTenantConfig(id, enabled); } finally { setToggling(null); }
|
||||
}}
|
||||
onDeleteTenantConfig={async (id) => {
|
||||
setToggling(id);
|
||||
try { await deleteTenantConfig(id); } finally { setToggling(null); }
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={skill.enabled !== false}
|
||||
disabled={toggling === skill.id}
|
||||
onCheckedChange={(checked) => handleToggle(skill, checked)}
|
||||
title={skill.enabled !== false ? t("toggle.disable") : t("toggle.enable")}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -372,3 +394,72 @@ export function SkillsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillTenantOverride({
|
||||
skill,
|
||||
toggling,
|
||||
onSetTenantConfig,
|
||||
onDeleteTenantConfig,
|
||||
}: {
|
||||
skill: SkillInfo;
|
||||
toggling: boolean;
|
||||
onSetTenantConfig: (id: string, enabled: boolean) => Promise<void>;
|
||||
onDeleteTenantConfig: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation("skills");
|
||||
const hasOverride = skill.tenant_enabled !== null && skill.tenant_enabled !== undefined;
|
||||
const checked = hasOverride ? (skill.tenant_enabled ?? false) : (skill.enabled !== false);
|
||||
const label = hasOverride
|
||||
? skill.tenant_enabled ? t("tenant.enabled") : t("tenant.disabled")
|
||||
: t("tenant.default");
|
||||
const badgeVariant = hasOverride
|
||||
? skill.tenant_enabled ? "default" : "secondary"
|
||||
: "outline";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant={badgeVariant as "default" | "secondary" | "outline"}
|
||||
className="h-5 cursor-default px-1.5 text-[10px] leading-none"
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("tenant.overrideHint")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={checked}
|
||||
disabled={toggling}
|
||||
onCheckedChange={(val) => onSetTenantConfig(skill.id!, val)}
|
||||
aria-label={t("tenant.overrideHint")}
|
||||
/>
|
||||
{hasOverride && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={toggling}
|
||||
onClick={() => onDeleteTenantConfig(skill.id!)}
|
||||
className="h-5 w-5 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("tenant.resetDefault")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export interface SkillInfo {
|
||||
is_system?: boolean;
|
||||
status?: string;
|
||||
enabled?: boolean;
|
||||
tenant_enabled?: boolean | null;
|
||||
author?: string;
|
||||
missing_deps?: string[];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user