Files
maestro/ui/src/components/spaces/SpaceToolSettings.tsx
T
oss-sync a6d1879d63
CI / build-and-test (push) Has been cancelled
sync: update from private repo (c764df2f)
2026-06-29 01:32:25 +00:00

291 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SpaceToolSettings.tsx — ワークスペースごとのツールポリシー設定 UI
*
* - 安全カテゴリ(sensitive=false: デフォルト ON のトグル群
* - センシティブカテゴリ(sensitive=true+ Bash: デフォルト OFF のトグル群。
* 各項目に 1 行のリスク説明を表示。
* - カテゴリ一覧は API から動的取得(ハードコードなし)。
* - オーナーのみ編集可(canManage 判定は SpaceMembersPanel と同じシグナル)。
* - 保存は PUT /api/local/spaces/:id/tool-policyreact-query mutation)。
*/
import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchSpaceToolPolicy,
fetchSpaceMembers,
updateSpaceToolPolicy,
type ToolCategory,
} from '../../api';
import { useAuthState } from '../../App';
import { splitCategories, buildPolicyPatch, countEnabledCategories } from '../../lib/toolPolicy';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
/** センシティブカテゴリ・ツールに表示する 1 行のリスク説明の翻訳キー。 */
const SENSITIVE_NOTE_KEYS: Record<string, string> = {
ssh: 'tools.sensitiveNote.ssh',
browser: 'tools.sensitiveNote.browser',
Bash: 'tools.sensitiveNote.Bash',
SpawnSubTask: 'tools.sensitiveNote.SpawnSubTask',
};
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
interface ToggleRowProps {
name: string;
enabled: boolean;
note?: string;
disabled: boolean;
disabledReason?: string;
onChange: (enabled: boolean) => void;
}
function ToggleRow({ name, enabled, note, disabled, disabledReason, onChange }: ToggleRowProps) {
return (
<div className="flex items-start gap-3 py-2.5 border-b border-hairline last:border-b-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-slate-900">{name}</span>
{disabled && disabledReason && (
<span className="text-2xs text-slate-400">({disabledReason})</span>
)}
</div>
{note && (
<p className="text-2xs text-slate-500 leading-relaxed mt-0.5">{note}</p>
)}
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
disabled={disabled}
onClick={() => onChange(!enabled)}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent-ring focus:ring-offset-1 disabled:opacity-40 disabled:cursor-not-allowed ${
enabled ? 'bg-accent' : 'bg-slate-300 dark:bg-slate-600'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform ${
enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
}`}
/>
</button>
</div>
);
}
export function SpaceToolSettings({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
const { t } = useTranslation('spaces');
const auth = useAuthState();
const qc = useQueryClient();
const noteFor = (name: string): string | undefined => {
const key = SENSITIVE_NOTE_KEYS[name];
return key ? t(key) : undefined;
};
// メンバー一覧から canManage を判定(SpaceMembersPanel と同じロジック)
const { data: members } = useQuery({
queryKey: ['space-members', spaceId],
queryFn: () => fetchSpaceMembers(spaceId),
staleTime: 30_000,
});
const ownerRow = (members ?? []).find(m => m.isOwner);
const canManage =
auth.mode === 'disabled' ||
(auth.mode === 'authenticated' &&
(auth.user.role === 'admin' || (!!ownerRow && ownerRow.userId === auth.user.id)));
// ツールポリシー取得
const { data, isLoading, isError, error } = useQuery({
queryKey: ['space-tool-policy', spaceId],
queryFn: () => fetchSpaceToolPolicy(spaceId),
staleTime: 30_000,
});
// UI ローカルのトグル状態(未保存の変更を保持)
const [toggledSafe, setToggledSafe] = useState<Record<string, boolean>>({});
const [toggledSens, setToggledSens] = useState<Record<string, boolean>>({});
const [savedState, setSavedState] = useState<'idle' | 'saved' | 'error'>('idle');
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ['space-tool-policy', spaceId] });
};
const saveMut = useMutation({
mutationFn: (patch: { disabledSafe: string[]; enabledSensitive: string[] }) =>
updateSpaceToolPolicy(spaceId, patch),
onSuccess: () => {
setSavedState('saved');
setToggledSafe({});
setToggledSens({});
invalidate();
setTimeout(() => setSavedState('idle'), 2000);
},
onError: (e) => {
setSavedState('error');
showToast?.(t('tools.toast.saveFailed', { msg: errMsg(e) }), 'error');
setTimeout(() => setSavedState('idle'), 3000);
},
});
const handleSave = () => {
if (!data) return;
const patch = buildPolicyPatch(data.categories, toggledSafe, toggledSens, data.sensitiveTools ?? []);
saveMut.mutate(patch);
};
const hasPendingChanges = Object.keys(toggledSafe).length > 0 || Object.keys(toggledSens).length > 0;
if (isLoading) {
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
<div className="text-[13px] text-slate-400">{t('common:loading')}</div>
</div>
</div>
);
}
if (isError || !data) {
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
<div className="text-[13px] text-red-600">
{t('tools.fetchError', { msg: errMsg(error) })}
</div>
</div>
</div>
);
}
const { safe, sensitive } = splitCategories(data.categories);
const enabledCount = countEnabledCategories(data.categories, toggledSafe, toggledSens);
const readonlyReason = t('tools.readonlyReason');
// センシティブグループ: カテゴリ + BashsensitiveTools から取得)
// sensitiveTools は Bash など個別ツールで sensitive=true なもの
const sensitiveBash = data.sensitiveTools ?? [];
const resolveEnabled = (cat: ToolCategory, map: Record<string, boolean>) => {
return Object.prototype.hasOwnProperty.call(map, cat.name) ? map[cat.name] : cat.enabled;
};
const resolveSensToolEnabled = (toolName: string, apiEnabled: boolean) => {
return Object.prototype.hasOwnProperty.call(toggledSens, toolName)
? toggledSens[toolName]
: apiEnabled;
};
return (
<div className="h-full overflow-y-auto" data-testid="space-tool-settings">
<div className="max-w-2xl mx-auto px-6 py-8 space-y-6">
{/* ヘッダー */}
<div>
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('tools.heading')}</h2>
<p className="text-[13px] text-slate-500 leading-relaxed">
{t('tools.intro')}
</p>
<p className="text-[13px] text-slate-500 mt-1">
<Trans
t={t}
i18nKey="tools.enabledCount"
values={{ count: enabledCount }}
components={{ strong: <span className="font-semibold text-slate-700" /> }}
/>
</p>
</div>
{/* 安全カテゴリ(デフォルト ON) */}
<section>
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-2">
{t('tools.standardCategories')}
</h3>
{safe.length === 0 ? (
<p className="text-[13px] text-slate-400">{t('tools.noStandardCategories')}</p>
) : (
<div className="rounded-md border border-hairline bg-surface/40 divide-y divide-hairline overflow-hidden px-3">
{safe.map(cat => (
<ToggleRow
key={cat.name}
name={cat.name}
enabled={resolveEnabled(cat, toggledSafe)}
disabled={!canManage || saveMut.isPending}
disabledReason={!canManage ? readonlyReason : undefined}
onChange={v => setToggledSafe(prev => ({ ...prev, [cat.name]: v }))}
/>
))}
</div>
)}
</section>
{/* センシティブカテゴリ(デフォルト OFF) */}
<section>
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-1">
{t('tools.sensitiveTools')}
</h3>
<p className="text-2xs text-slate-500 leading-relaxed mb-2">
{t('tools.sensitiveWarning')}
</p>
{sensitive.length === 0 && sensitiveBash.length === 0 ? (
<p className="text-[13px] text-slate-400">{t('tools.noSensitiveCategories')}</p>
) : (
<div className="rounded-md border border-hairline bg-amber-50/40 dark:bg-amber-900/10 divide-y divide-hairline overflow-hidden px-3">
{/* センシティブカテゴリ(ssh / browser 等) */}
{sensitive.map(cat => (
<ToggleRow
key={cat.name}
name={cat.name}
enabled={resolveEnabled(cat, toggledSens)}
note={noteFor(cat.name)}
disabled={!canManage || saveMut.isPending}
disabledReason={!canManage ? readonlyReason : undefined}
onChange={v => setToggledSens(prev => ({ ...prev, [cat.name]: v }))}
/>
))}
{/* 個別センシティブツール(Bash 等) */}
{sensitiveBash.map(tool => (
<ToggleRow
key={tool.name}
name={tool.name}
enabled={resolveSensToolEnabled(tool.name, tool.enabled)}
note={noteFor(tool.name)}
disabled={!canManage || saveMut.isPending}
disabledReason={!canManage ? readonlyReason : undefined}
onChange={v => setToggledSens(prev => ({ ...prev, [tool.name]: v }))}
/>
))}
</div>
)}
</section>
{/* 保存ボタン */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={!canManage || saveMut.isPending || !hasPendingChanges}
className="px-4 py-1.5 rounded-md text-sm font-semibold bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{saveMut.isPending ? t('tools.saving') : t('common:save')}
</button>
{savedState === 'saved' && (
<span className="text-[13px] text-green-600">{t('tools.saved')}</span>
)}
{savedState === 'error' && (
<span className="text-[13px] text-red-600">{t('tools.saveFailedInline')}</span>
)}
{!canManage && (
<span className="text-[13px] text-slate-400">{readonlyReason}</span>
)}
</div>
</div>
</div>
);
}