/** * SpaceToolSettings.tsx — ワークスペースごとのツールポリシー設定 UI * * - 安全カテゴリ(sensitive=false): デフォルト ON のトグル群 * - センシティブカテゴリ(sensitive=true)+ Bash: デフォルト OFF のトグル群。 * 各項目に 1 行のリスク説明を表示。 * - カテゴリ一覧は API から動的取得(ハードコードなし)。 * - オーナーのみ編集可(canManage 判定は SpaceMembersPanel と同じシグナル)。 * - 保存は PUT /api/local/spaces/:id/tool-policy(react-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 = { 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 (
{name} {disabled && disabledReason && ( ({disabledReason}) )}
{note && (

{note}

)}
); } 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>({}); const [toggledSens, setToggledSens] = useState>({}); 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 (
{t('common:loading')}
); } if (isError || !data) { return (
{t('tools.fetchError', { msg: errMsg(error) })}
); } const { safe, sensitive } = splitCategories(data.categories); const enabledCount = countEnabledCategories(data.categories, toggledSafe, toggledSens); const readonlyReason = t('tools.readonlyReason'); // センシティブグループ: カテゴリ + Bash(sensitiveTools から取得) // sensitiveTools は Bash など個別ツールで sensitive=true なもの const sensitiveBash = data.sensitiveTools ?? []; const resolveEnabled = (cat: ToolCategory, map: Record) => { 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 (
{/* ヘッダー */}

{t('tools.heading')}

{t('tools.intro')}

}} />

{/* 安全カテゴリ(デフォルト ON) */}

{t('tools.standardCategories')}

{safe.length === 0 ? (

{t('tools.noStandardCategories')}

) : (
{safe.map(cat => ( setToggledSafe(prev => ({ ...prev, [cat.name]: v }))} /> ))}
)}
{/* センシティブカテゴリ(デフォルト OFF) */}

{t('tools.sensitiveTools')}

{t('tools.sensitiveWarning')}

{sensitive.length === 0 && sensitiveBash.length === 0 ? (

{t('tools.noSensitiveCategories')}

) : (
{/* センシティブカテゴリ(ssh / browser 等) */} {sensitive.map(cat => ( setToggledSens(prev => ({ ...prev, [cat.name]: v }))} /> ))} {/* 個別センシティブツール(Bash 等) */} {sensitiveBash.map(tool => ( setToggledSens(prev => ({ ...prev, [tool.name]: v }))} /> ))}
)}
{/* 保存ボタン */}
{savedState === 'saved' && ( {t('tools.saved')} )} {savedState === 'error' && ( {t('tools.saveFailedInline')} )} {!canManage && ( {readonlyReason} )}
); }