8ad1a8a8da
Co-authored-by: Cursor <cursoragent@cursor.com>
281 lines
8.8 KiB
TypeScript
281 lines
8.8 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import {
|
||
clearUserPolicyOverrides,
|
||
getRolePolicies,
|
||
getUserPolicies,
|
||
listPolicyCatalog,
|
||
updateRolePolicies,
|
||
updateUserPolicies,
|
||
} from '../api/client';
|
||
import type { AdminUserRow, PolicyDefinition, PolicyMap } from '../types';
|
||
|
||
const RISK_LABEL: Record<string, string> = {
|
||
low: '低',
|
||
medium: '中',
|
||
high: '高',
|
||
};
|
||
|
||
function PolicyGrid({
|
||
catalog,
|
||
values,
|
||
disabled,
|
||
onChange,
|
||
}: {
|
||
catalog: PolicyDefinition[];
|
||
values: PolicyMap;
|
||
disabled?: boolean;
|
||
onChange: (key: string, value: PolicyMap[string]) => void;
|
||
}) {
|
||
const grouped = catalog.reduce<Record<string, PolicyDefinition[]>>((acc, item) => {
|
||
acc[item.category] ??= [];
|
||
acc[item.category].push(item);
|
||
return acc;
|
||
}, {});
|
||
|
||
return (
|
||
<div className="capability-grid">
|
||
{Object.entries(grouped).map(([category, items]) => (
|
||
<div key={category} className="capability-group">
|
||
<h3>{category}</h3>
|
||
<ul>
|
||
{items.map((item) => (
|
||
<li key={item.key}>
|
||
<label>
|
||
{item.type === 'boolean' ? (
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(values[item.key])}
|
||
disabled={disabled}
|
||
onChange={(e) => onChange(item.key, e.target.checked)}
|
||
/>
|
||
) : (
|
||
<select
|
||
value={String(values[item.key] ?? item.defaultValue)}
|
||
disabled={disabled}
|
||
onChange={(e) => onChange(item.key, e.target.value)}
|
||
>
|
||
{(item.options ?? []).map((opt) => (
|
||
<option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
<span className="capability-label">
|
||
{item.label}
|
||
<span className={`risk-pill risk-${item.risk}`}>
|
||
风险 {RISK_LABEL[item.risk] ?? item.risk}
|
||
</span>
|
||
</span>
|
||
<span className="muted capability-desc">{item.description}</span>
|
||
</label>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function PolicySettings({
|
||
users,
|
||
userId,
|
||
userOnly = false,
|
||
}: {
|
||
users: AdminUserRow[];
|
||
userId?: string;
|
||
userOnly?: boolean;
|
||
}) {
|
||
const [catalog, setCatalog] = useState<PolicyDefinition[]>([]);
|
||
const [rolePolicies, setRolePolicies] = useState<PolicyMap>({});
|
||
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
|
||
const [userPolicies, setUserPolicies] = useState<PolicyMap>({});
|
||
const [userOverrides, setUserOverrides] = useState<PolicyMap>({});
|
||
const [userUnrestricted, setUserUnrestricted] = useState(false);
|
||
const [loading, setLoading] = useState(true);
|
||
const [message, setMessage] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const [catalogItems, roleState] = await Promise.all([
|
||
listPolicyCatalog(),
|
||
getRolePolicies('user'),
|
||
]);
|
||
setCatalog(catalogItems);
|
||
setRolePolicies(roleState.policies);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '加载安全策略失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const loadUserPolicies = useCallback(async (userId: string) => {
|
||
if (!userId) {
|
||
setUserPolicies({});
|
||
setUserOverrides({});
|
||
setUserUnrestricted(false);
|
||
return;
|
||
}
|
||
try {
|
||
const state = await getUserPolicies(userId);
|
||
setUserPolicies(state.policies);
|
||
setUserOverrides(state.overrides);
|
||
setUserUnrestricted(state.unrestricted);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '加载用户策略失败');
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
useEffect(() => {
|
||
void loadUserPolicies(selectedUserId);
|
||
}, [loadUserPolicies, selectedUserId]);
|
||
|
||
useEffect(() => {
|
||
if (userId) {
|
||
setSelectedUserId(userId);
|
||
}
|
||
}, [userId]);
|
||
|
||
const saveRoleDefaults = async () => {
|
||
setMessage(null);
|
||
setError(null);
|
||
try {
|
||
const result = await updateRolePolicies('user', rolePolicies);
|
||
setRolePolicies(result.policies);
|
||
setMessage('普通用户默认安全策略已保存');
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '保存失败');
|
||
}
|
||
};
|
||
|
||
const saveUserOverrides = async () => {
|
||
if (!selectedUserId) return;
|
||
setMessage(null);
|
||
setError(null);
|
||
try {
|
||
const result = await updateUserPolicies(selectedUserId, userPolicies);
|
||
setUserPolicies(result.policies);
|
||
await loadUserPolicies(selectedUserId);
|
||
setMessage('用户单独策略已保存');
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '保存失败');
|
||
}
|
||
};
|
||
|
||
const resetUserOverrides = async () => {
|
||
if (!selectedUserId) return;
|
||
setMessage(null);
|
||
setError(null);
|
||
try {
|
||
await clearUserPolicyOverrides(selectedUserId);
|
||
await loadUserPolicies(selectedUserId);
|
||
setMessage('已恢复为角色默认策略');
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '重置失败');
|
||
}
|
||
};
|
||
|
||
const regularUsers = users.filter((user) => user.role === 'user');
|
||
|
||
return (
|
||
<section className="admin-card">
|
||
<div className="admin-card-head">
|
||
<h2>安全策略</h2>
|
||
<button type="button" className="ghost-btn" onClick={() => void load()}>
|
||
刷新
|
||
</button>
|
||
</div>
|
||
<p className="muted capability-intro">
|
||
控制 TKMind 模式、工作区只读、网络出站与 API 代理锁定。与上方「能力权限」叠加生效(策略会进一步收紧高风险能力)。
|
||
</p>
|
||
{error && <p className="banner banner-error">{error}</p>}
|
||
{message && <p className="banner banner-info">{message}</p>}
|
||
{loading ? (
|
||
<p className="muted">加载中…</p>
|
||
) : (
|
||
<>
|
||
{!userOnly && (
|
||
<>
|
||
<h3 className="capability-section-title">普通用户默认策略</h3>
|
||
<PolicyGrid
|
||
catalog={catalog}
|
||
values={rolePolicies}
|
||
onChange={(key, value) => setRolePolicies((prev) => ({ ...prev, [key]: value }))}
|
||
/>
|
||
<div className="capability-actions">
|
||
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
|
||
保存默认策略
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{!userOnly && <h3 className="capability-section-title">单用户策略覆盖</h3>}
|
||
{!userOnly && (
|
||
<div className="admin-form capability-user-picker">
|
||
<select
|
||
value={selectedUserId}
|
||
onChange={(e) => setSelectedUserId(e.target.value)}
|
||
>
|
||
<option value="">选择用户(可选)</option>
|
||
{regularUsers.map((user) => (
|
||
<option key={user.id} value={user.id}>
|
||
{user.displayName} (@{user.username})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
{selectedUserId && (
|
||
<>
|
||
{userUnrestricted ? (
|
||
<p className="muted">该用户为管理员,不受策略限制。</p>
|
||
) : (
|
||
<>
|
||
{Object.keys(userOverrides).length > 0 && (
|
||
<p className="muted">
|
||
已设置 {Object.keys(userOverrides).length} 项单独覆盖。
|
||
</p>
|
||
)}
|
||
<PolicyGrid
|
||
catalog={catalog}
|
||
values={userPolicies}
|
||
onChange={(key, value) =>
|
||
setUserPolicies((prev) => ({ ...prev, [key]: value }))
|
||
}
|
||
/>
|
||
<div className="capability-actions">
|
||
<button
|
||
type="button"
|
||
className="send-btn"
|
||
onClick={() => void saveUserOverrides()}
|
||
>
|
||
保存用户策略
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="ghost-btn"
|
||
onClick={() => void resetUserOverrides()}
|
||
>
|
||
恢复角色默认
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|