sync: update from private repo (f6d625db)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-26 03:35:45 +00:00
parent 29ccaf1e92
commit b857c33ef6
371 changed files with 31312 additions and 8172 deletions
@@ -0,0 +1,289 @@
/**
* 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',
};
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>
);
}