mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 13:04:57 +00:00
feat(ui): tenant-scope aware builtin tool settings editor
Phase 5 of tenant tool config refactor. Wires the existing builtin tools
page + settings dialog to the tenant-config HTTP endpoint shipped in
Phase 4, so tenant admins can edit per-tenant tool overrides from the
same "Settings" button master admins already use.
- BuiltinToolData DTO gains tenant_settings (already returned by the
GET /v1/tools/builtin enrich path when request is tenant-scoped).
- useBuiltinTools hook gains setTenantSettings + clearTenantSettings —
both hit PUT /v1/tools/builtin/{name}/tenant-config, with null clearing
the settings column while preserving tenant_enabled (column-list
upsert on the store side, shipped in Phase 2).
- BuiltinToolSettingsDialog accepts a tenantScope flag. In tenant mode:
- initial values come from tenant_settings ?? settings (pre-fill
with global defaults when creating a fresh override)
- a small badge + hint line communicates the mode
- optional "Reset to global default" appears when an override exists
- BuiltinToolsPage branches the save callback: master scope → updateTool
(global settings), non-master → setTenantSettings. The backend enforces
the same rule defensively per Phase 0b, so this branch is a UX guard
(avoid 403) not a security boundary.
- New en/vi/zh keys: tenantOverrideBadge, tenantOverrideHint,
tenantOverrideNewHint, resetToGlobalDefault.
Verified: pnpm build clean (tsc -b + vite).
This commit is contained in:
@@ -85,6 +85,10 @@
|
||||
"tenantDisabled": "Disabled for this tenant",
|
||||
"tenantDefault": "Using default",
|
||||
"resetToDefault": "Reset to default",
|
||||
"tenantOverrideBadge": "Tenant override",
|
||||
"tenantOverrideHint": "Editing this tenant's override. Changes apply only to this tenant.",
|
||||
"tenantOverrideNewHint": "Creating a new override for this tenant. Pre-filled with global defaults.",
|
||||
"resetToGlobalDefault": "Reset to global default",
|
||||
"unconfiguredWarning": "{{count}} tools need provider configuration for agents to use them at full capacity.",
|
||||
"unconfiguredAction": "Configure",
|
||||
"categories": {
|
||||
|
||||
@@ -85,6 +85,10 @@
|
||||
"tenantDisabled": "Tắt cho tenant này",
|
||||
"tenantDefault": "Dùng mặc định",
|
||||
"resetToDefault": "Đặt lại mặc định",
|
||||
"tenantOverrideBadge": "Ghi đè tenant",
|
||||
"tenantOverrideHint": "Đang chỉnh ghi đè của tenant này. Thay đổi chỉ áp dụng cho tenant này.",
|
||||
"tenantOverrideNewHint": "Tạo ghi đè mới cho tenant này. Đã điền sẵn giá trị mặc định toàn cục.",
|
||||
"resetToGlobalDefault": "Đặt lại về mặc định toàn cục",
|
||||
"unconfiguredWarning": "{{count}} công cụ cần cấu hình provider để agent sử dụng đầy đủ năng lực.",
|
||||
"unconfiguredAction": "Cấu hình",
|
||||
"categories": {
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
"tenantDisabled": "已为此租户禁用",
|
||||
"tenantDefault": "使用默认",
|
||||
"resetToDefault": "恢复默认",
|
||||
"tenantOverrideBadge": "租户覆盖",
|
||||
"tenantOverrideHint": "正在编辑该租户的覆盖配置,更改仅对该租户生效。",
|
||||
"tenantOverrideNewHint": "为该租户创建新的覆盖配置,已预填全局默认值。",
|
||||
"resetToGlobalDefault": "恢复为全局默认",
|
||||
"unconfiguredWarning": "{{count}} 个工具需要配置 Provider 才能让 Agent 发挥全部能力。",
|
||||
"unconfiguredAction": "配置",
|
||||
"description": "管理系统内置工具。全局启用/禁用或配置设置。",
|
||||
|
||||
@@ -25,39 +25,96 @@ interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (name: string, settings: Record<string, unknown>) => Promise<void>;
|
||||
/**
|
||||
* When true the dialog is editing a tenant override. Initial form values
|
||||
* are drawn from `tool.tenant_settings ?? tool.settings` (fall back to the
|
||||
* global default when no override exists yet) and the parent page is
|
||||
* expected to route the save through the tenant-config endpoint. When
|
||||
* false the dialog edits the global `tool.settings` directly — parent
|
||||
* must enforce master-scope (the backend rejects non-master writes).
|
||||
*/
|
||||
tenantScope?: boolean;
|
||||
/**
|
||||
* Optional reset handler. When provided, dialogs rendered in tenant scope
|
||||
* with an existing override show a "Reset to default" button that calls
|
||||
* this with the tool name — parent maps it to PUT tenant-config with
|
||||
* settings:null, clearing the override while preserving tenant_enabled.
|
||||
*/
|
||||
onResetToDefault?: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function BuiltinToolSettingsDialog({ tool, open, onOpenChange, onSave }: Props) {
|
||||
export function BuiltinToolSettingsDialog({
|
||||
tool,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
tenantScope = false,
|
||||
onResetToDefault,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("tools");
|
||||
const isMedia = tool ? MEDIA_TOOLS.has(tool.name) : false;
|
||||
const isKG = tool?.name === KG_TOOL;
|
||||
const isWebFetch = tool?.name === WEB_FETCH_TOOL;
|
||||
const wide = isMedia || isKG || isWebFetch;
|
||||
|
||||
// Tenant-scope overlay: prefer the tenant override when present; fall back
|
||||
// to the global default so the form opens pre-populated with something
|
||||
// sensible when the admin is creating a new override.
|
||||
const initialSettings: Record<string, unknown> =
|
||||
(tenantScope ? (tool?.tenant_settings ?? tool?.settings) : tool?.settings) ?? {};
|
||||
const hasTenantOverride = tenantScope && tool?.tenant_settings != null;
|
||||
|
||||
const modeBadge = tenantScope ? (
|
||||
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded bg-amber-100 px-1.5 py-0.5 font-medium text-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
|
||||
{t("builtin.tenantOverrideBadge")}
|
||||
</span>
|
||||
<span>{hasTenantOverride ? t("builtin.tenantOverrideHint") : t("builtin.tenantOverrideNewHint")}</span>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={wide ? "sm:max-w-2xl" : "sm:max-w-md"}>
|
||||
{modeBadge}
|
||||
{isWebFetch && tool ? (
|
||||
<WebFetchExtractorChainForm
|
||||
initialSettings={tool.settings ?? {}}
|
||||
initialSettings={initialSettings}
|
||||
onSave={(settings) => onSave(tool.name, settings).then(() => onOpenChange(false))}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
/>
|
||||
) : isMedia && tool ? (
|
||||
<MediaProviderChainForm
|
||||
toolName={tool.name}
|
||||
initialSettings={tool.settings ?? {}}
|
||||
initialSettings={initialSettings}
|
||||
onSave={(settings) => onSave(tool.name, settings).then(() => onOpenChange(false))}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
/>
|
||||
) : isKG && tool ? (
|
||||
<KGSettingsForm
|
||||
initialSettings={tool.settings ?? {}}
|
||||
initialSettings={initialSettings}
|
||||
onSave={(settings) => onSave(tool.name, settings).then(() => onOpenChange(false))}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
/>
|
||||
) : (
|
||||
<JsonSettingsForm tool={tool} onOpenChange={onOpenChange} onSave={onSave} />
|
||||
<JsonSettingsForm
|
||||
tool={tool}
|
||||
initialSettings={initialSettings}
|
||||
onOpenChange={onOpenChange}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)}
|
||||
{hasTenantOverride && onResetToDefault && tool ? (
|
||||
<div className="mt-2 border-t pt-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResetToDefault(tool.name).then(() => onOpenChange(false))}
|
||||
className="text-muted-foreground hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("builtin.resetToGlobalDefault")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -66,10 +123,12 @@ export function BuiltinToolSettingsDialog({ tool, open, onOpenChange, onSave }:
|
||||
|
||||
function JsonSettingsForm({
|
||||
tool,
|
||||
initialSettings,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: {
|
||||
tool: BuiltinToolData | null;
|
||||
initialSettings: Record<string, unknown>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (name: string, settings: Record<string, unknown>) => Promise<void>;
|
||||
}) {
|
||||
@@ -81,11 +140,15 @@ function JsonSettingsForm({
|
||||
|
||||
useEffect(() => {
|
||||
if (tool) {
|
||||
setJson(JSON.stringify(tool.settings ?? {}, null, 2));
|
||||
// initialSettings is pre-resolved by the parent to account for tenant
|
||||
// scope (tenant_settings ?? settings). Re-hydrate every time the tool
|
||||
// or the resolved initial changes — handles switch-between-tools and
|
||||
// tenant-override-cleared flows.
|
||||
setJson(JSON.stringify(initialSettings, null, 2));
|
||||
setError("");
|
||||
setValidJson(true);
|
||||
}
|
||||
}, [tool]);
|
||||
}, [tool, initialSettings]);
|
||||
|
||||
const handleJsonChange = (text: string) => {
|
||||
setJson(text);
|
||||
|
||||
@@ -19,6 +19,13 @@ const CATEGORY_ORDER = [
|
||||
"sessions", "messaging", "scheduling", "subagents", "skills", "delegation", "teams",
|
||||
];
|
||||
|
||||
// Hardcoded master tenant UUID; mirrors backend store.MasterTenantID. When
|
||||
// the current tenant is NOT the master, UI routes settings writes through
|
||||
// the tenant-config endpoint. Backend enforces the same rule defensively —
|
||||
// this is purely a UX guard so tenant admins never see a 403 from the
|
||||
// global endpoint.
|
||||
const MASTER_TENANT_ID = "0193a5b0-7000-7000-8000-000000000001";
|
||||
|
||||
/** Media tool that is enabled but has no provider chain configured */
|
||||
function needsProviderConfig(tool: BuiltinToolData): boolean {
|
||||
if (!MEDIA_TOOLS.has(tool.name) || !tool.enabled) return false;
|
||||
@@ -30,8 +37,18 @@ function needsProviderConfig(tool: BuiltinToolData): boolean {
|
||||
|
||||
export function BuiltinToolsPage() {
|
||||
const { t } = useTranslation("tools");
|
||||
const { tools, loading, refresh, updateTool, setTenantConfig, deleteTenantConfig } = useBuiltinTools();
|
||||
const {
|
||||
tools,
|
||||
loading,
|
||||
refresh,
|
||||
updateTool,
|
||||
setTenantConfig,
|
||||
deleteTenantConfig,
|
||||
setTenantSettings,
|
||||
clearTenantSettings,
|
||||
} = useBuiltinTools();
|
||||
const { currentTenantId } = useTenants();
|
||||
const hasTenantScope = !!currentTenantId && currentTenantId !== MASTER_TENANT_ID;
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && tools.length === 0);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -64,8 +81,22 @@ export function BuiltinToolsPage() {
|
||||
await updateTool(tool.name, { enabled: !tool.enabled });
|
||||
};
|
||||
|
||||
// Route settings writes through the correct endpoint based on scope:
|
||||
// - Master scope → PUT /v1/tools/builtin/{name} (global default)
|
||||
// - Tenant scope → PUT /v1/tools/builtin/{name}/tenant-config (override)
|
||||
// Backend enforces master-scope defensively (Phase 0b), so even if this
|
||||
// UI check drifted, a tenant admin would hit 403 instead of corrupting
|
||||
// the global defaults.
|
||||
const handleSaveSettings = async (name: string, settings: Record<string, unknown>) => {
|
||||
await updateTool(name, { settings });
|
||||
if (hasTenantScope) {
|
||||
await setTenantSettings(name, settings);
|
||||
} else {
|
||||
await updateTool(name, { settings });
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetTenantSettings = async (name: string) => {
|
||||
await clearTenantSettings(name);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -161,6 +192,8 @@ export function BuiltinToolsPage() {
|
||||
if (!open) setSettingsTool(null);
|
||||
}}
|
||||
onSave={handleSaveSettings}
|
||||
tenantScope={hasTenantScope}
|
||||
onResetToDefault={hasTenantScope ? handleResetTenantSettings : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,10 @@ export interface BuiltinToolData {
|
||||
enabled: boolean;
|
||||
tenant_enabled: boolean | null;
|
||||
settings: Record<string, unknown>;
|
||||
// Tenant override for settings JSON. Null when no tenant override
|
||||
// exists (row in builtin_tool_tenant_configs has settings column NULL
|
||||
// or row absent). Present only when request is tenant-scoped.
|
||||
tenant_settings: Record<string, unknown> | null;
|
||||
requires: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
@@ -71,7 +75,33 @@ export function useBuiltinTools() {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
[http, queryClient, invalidate],
|
||||
);
|
||||
|
||||
// setTenantSettings writes the JSONB `settings` column of
|
||||
// builtin_tool_tenant_configs for the current tenant. Passing `null`
|
||||
// clears the override (backend maps literal `null` → SQL NULL) while
|
||||
// preserving the `enabled` column on the same row.
|
||||
const setTenantSettings = useCallback(
|
||||
async (name: string, settings: Record<string, unknown> | null) => {
|
||||
try {
|
||||
queryClient.setQueryData<BuiltinToolData[]>(queryKeys.builtinTools.all, (old) =>
|
||||
old?.map((t) => (t.name === name ? { ...t, tenant_settings: settings } : t)),
|
||||
);
|
||||
await http.put(`/v1/tools/builtin/${name}/tenant-config`, { settings });
|
||||
await invalidate();
|
||||
toast.success(i18next.t("tools:builtin.settingsDialog.toast.saved"));
|
||||
} catch (err) {
|
||||
toast.error(i18next.t("tools:builtin.settingsDialog.toast.failed"), userFriendlyError(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, queryClient, invalidate],
|
||||
);
|
||||
|
||||
const clearTenantSettings = useCallback(
|
||||
(name: string) => setTenantSettings(name, null),
|
||||
[setTenantSettings],
|
||||
);
|
||||
|
||||
const deleteTenantConfig = useCallback(
|
||||
@@ -88,5 +118,14 @@ export function useBuiltinTools() {
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
return { tools, loading, refresh: invalidate, updateTool, setTenantConfig, deleteTenantConfig };
|
||||
return {
|
||||
tools,
|
||||
loading,
|
||||
refresh: invalidate,
|
||||
updateTool,
|
||||
setTenantConfig,
|
||||
deleteTenantConfig,
|
||||
setTenantSettings,
|
||||
clearTenantSettings,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user