290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
/**
|
||
* 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<string, string> = {
|
||
ssh: 'tools.sensitiveNote.ssh',
|
||
browser: 'tools.sensitiveNote.browser',
|
||
Bash: 'tools.sensitiveNote.Bash',
|
||
};
|
||
|
||
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');
|
||
|
||
// センシティブグループ: カテゴリ + Bash(sensitiveTools から取得)
|
||
// 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>
|
||
);
|
||
}
|