This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user