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:
viettranx committed 2026-03-25 22:15:01 +07:00
1 parent f94fc1528e
commit 31c41dac54
10 files changed
+181 -15

No files matched your search

+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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)
+27 -3
View File
@@ -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,
}))
+7
View File
@@ -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...",
+7
View File
@@ -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...",
+7
View File
@@ -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,
};
}
+99 -8
View File
@@ -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>
);
}
+1
View File
@@ -10,6 +10,7 @@ export interface SkillInfo {
is_system?: boolean;
status?: string;
enabled?: boolean;
tenant_enabled?: boolean | null;
author?: string;
missing_deps?: string[];
}