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:
viettranx
2026-04-12 10:27:44 +07:00
parent 6d7473b56f
commit 1e5e84d55c
6 changed files with 158 additions and 11 deletions
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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,
};
}