feat: refine api key permissions and admin routes
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import type { FormEvent, ReactNode } from 'react';
|
||||
import { CreditCard, KeyRound, ListChecks, UserRound } from 'lucide-react';
|
||||
import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Copy, CreditCard, KeyRound, ListChecks, Plus, ShieldCheck, Trash2, UserRound } from 'lucide-react';
|
||||
import type { GatewayAccessRuleBatchRequest, GatewayApiKey, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import type { ConsoleData } from '../app-state';
|
||||
import { EntityTable } from '../components/EntityTable';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Tabs } from '../components/ui';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, DateTimePicker, FormDialog, Input, Label, Table, TableCell, TableHead, TableRow, Tabs } from '../components/ui';
|
||||
import { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor';
|
||||
import type { ApiKeyForm, LoadState, WorkspaceSection } from '../types';
|
||||
|
||||
const tabs = [
|
||||
@@ -15,12 +17,17 @@ const tabs = [
|
||||
export function WorkspacePage(props: {
|
||||
apiKeyForm: ApiKeyForm;
|
||||
apiKeySecret: string;
|
||||
apiKeySecretsById: Record<string, string>;
|
||||
apiKeyPolicyModels: PlatformModel[];
|
||||
data: ConsoleData;
|
||||
message: string;
|
||||
section: WorkspaceSection;
|
||||
state: LoadState;
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||
onSectionChange: (value: WorkspaceSection) => void;
|
||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -101,53 +108,191 @@ function BillingPanel() {
|
||||
function ApiKeyPanel(props: {
|
||||
apiKeyForm: ApiKeyForm;
|
||||
apiKeySecret: string;
|
||||
apiKeySecretsById: Record<string, string>;
|
||||
apiKeyPolicyModels: PlatformModel[];
|
||||
data: ConsoleData;
|
||||
message: string;
|
||||
state: LoadState;
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
||||
}) {
|
||||
const latestUsableKey = props.apiKeySecret ? props.data.apiKeys[0] : undefined;
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [policyApiKeyId, setPolicyApiKeyId] = useState('');
|
||||
const [pendingDelete, setPendingDelete] = useState<GatewayApiKey | null>(null);
|
||||
const [localMessage, setLocalMessage] = useState('');
|
||||
const selectedPolicyKey = useMemo(
|
||||
() => props.data.apiKeys.find((item) => item.id === policyApiKeyId),
|
||||
[policyApiKeyId, props.data.apiKeys],
|
||||
);
|
||||
const permissionPlatforms = useMemo(() => platformsForPermissionTree(props.apiKeyPolicyModels), [props.apiKeyPolicyModels]);
|
||||
|
||||
async function copyApiKey(item: GatewayApiKey) {
|
||||
const secret = apiKeySecretFor(item, props.apiKeySecretsById);
|
||||
if (!secret) return;
|
||||
await navigator.clipboard.writeText(secret);
|
||||
setLocalMessage(`已复制 ${item.name || item.keyPrefix}`);
|
||||
}
|
||||
|
||||
async function submitCreate(event: FormEvent<HTMLFormElement>) {
|
||||
try {
|
||||
await props.onSubmitApiKey(event);
|
||||
setCreateOpen(false);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteApiKey() {
|
||||
if (!pendingDelete) return;
|
||||
await props.onDeleteApiKey(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="contentGrid two">
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>创建 API Key</CardTitle>
|
||||
<div>
|
||||
<CardTitle>API Key</CardTitle>
|
||||
<p className="mutedText">按 Key 维护调用凭证、最近使用时间和平台/模型权限策略。</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={15} />
|
||||
创建 API Key
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="formGrid" onSubmit={props.onSubmitApiKey}>
|
||||
<Label>
|
||||
名称
|
||||
<Input value={props.apiKeyForm.name} onChange={(event) => props.onApiKeyFormChange({ name: event.target.value })} />
|
||||
</Label>
|
||||
<Button type="submit" disabled={props.state === 'loading'}>
|
||||
<KeyRound size={15} />
|
||||
创建
|
||||
</Button>
|
||||
{props.apiKeySecret && (
|
||||
<div className="createdApiKeyBox">
|
||||
<code className="secretBox">{props.apiKeySecret}</code>
|
||||
<Button type="button" variant="secondary" onClick={() => props.onUseApiKeyForPlayground(latestUsableKey?.id)}>
|
||||
使用测试
|
||||
</Button>
|
||||
{(localMessage || props.message) && <p className="formMessage">{localMessage || props.message}</p>}
|
||||
<Table className="apiKeyTable">
|
||||
<TableRow className="shTableHeader">
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>权限策略</TableHead>
|
||||
<TableHead>最近使用</TableHead>
|
||||
<TableHead>有效期</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
{props.data.apiKeys.length ? props.data.apiKeys.map((item) => {
|
||||
const secret = apiKeySecretFor(item, props.apiKeySecretsById);
|
||||
const summary = countAccessPermissionRules(props.data.accessRules, 'api_key', item.id);
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<span className="apiKeyNameCell">
|
||||
<strong>{item.name}</strong>
|
||||
<small>{item.keyPrefix}</small>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="apiKeySecretCell">
|
||||
<code>{secret ? maskApiKey(secret) : item.keyPrefix}</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={secret ? '复制 API Key' : '暂无可复制的完整 Key'}
|
||||
disabled={!secret}
|
||||
onClick={() => void copyApiKey(item)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button type="button" className="apiKeyPolicyButton" onClick={() => setPolicyApiKeyId(item.id)}>
|
||||
<ShieldCheck size={14} />
|
||||
<span>{permissionSummaryText(summary)}</span>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>{formatDateTime(item.lastUsedAt)}</TableCell>
|
||||
<TableCell>{formatDateTime(item.expiresAt)}</TableCell>
|
||||
<TableCell><Badge variant={item.status === 'active' ? 'success' : 'secondary'}>{item.status}</Badge></TableCell>
|
||||
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
|
||||
<TableCell>
|
||||
<Button type="button" variant="ghost" size="icon" title="删除 API Key" onClick={() => setPendingDelete(item)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}) : (
|
||||
<div className="emptyState">
|
||||
<strong>暂无 API Key</strong>
|
||||
<span>点击右上角创建调用凭证。</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Key 列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EntityTable
|
||||
columns={['名称', '前缀', '状态', '创建时间']}
|
||||
empty="暂无 API Key"
|
||||
rows={props.data.apiKeys.map((item) => [item.name, item.keyPrefix, item.status, new Date(item.createdAt).toLocaleString()])}
|
||||
|
||||
<FormDialog
|
||||
ariaLabel="创建 API Key"
|
||||
bodyClassName="apiKeyCreateDialogBody"
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button type="submit" size="sm" disabled={props.state === 'loading'}>
|
||||
<KeyRound size={15} />
|
||||
确认创建
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
open={createOpen}
|
||||
title="创建 API Key"
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSubmit={(event) => void submitCreate(event)}
|
||||
>
|
||||
<Label>
|
||||
名称
|
||||
<Input value={props.apiKeyForm.name} placeholder="例如:生产调用 Key" onChange={(event) => props.onApiKeyFormChange({ ...props.apiKeyForm, name: event.target.value })} />
|
||||
</Label>
|
||||
<Label>
|
||||
有效期
|
||||
<DateTimePicker
|
||||
value={props.apiKeyForm.expiresAt}
|
||||
placeholder="不设置则长期有效"
|
||||
onChange={(expiresAt) => props.onApiKeyFormChange({ ...props.apiKeyForm, expiresAt })}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</Label>
|
||||
</FormDialog>
|
||||
|
||||
<FormDialog
|
||||
ariaLabel="维护 API Key 权限策略"
|
||||
bodyClassName="apiKeyPolicyDialogBody"
|
||||
className="apiKeyPolicyDialog"
|
||||
footer={<Button type="button" size="sm" onClick={() => setPolicyApiKeyId('')}>关闭</Button>}
|
||||
open={Boolean(selectedPolicyKey)}
|
||||
title={selectedPolicyKey ? `权限策略:${selectedPolicyKey.name}` : '权限策略'}
|
||||
onClose={() => setPolicyApiKeyId('')}
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
>
|
||||
<AccessPermissionEditor
|
||||
accessRules={props.data.accessRules}
|
||||
metadataMode="api_key_permission_tree"
|
||||
platformModels={props.apiKeyPolicyModels}
|
||||
platforms={permissionPlatforms}
|
||||
state={props.state}
|
||||
subjectId={selectedPolicyKey?.id ?? ''}
|
||||
subjectType="api_key"
|
||||
onBatchAccessRules={props.onBatchAccessRules}
|
||||
/>
|
||||
</FormDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
confirmLabel="删除 API Key"
|
||||
description="删除后该 Key 将无法继续调用,关联的平台/模型权限策略会同步删除。"
|
||||
loading={props.state === 'loading'}
|
||||
open={Boolean(pendingDelete)}
|
||||
title={`确认删除 ${pendingDelete?.name ?? ''}?`}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={confirmDeleteApiKey}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,3 +340,48 @@ function InfoItem(props: { label: string; value: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function apiKeySecretFor(item: GatewayApiKey, secretsById: Record<string, string>) {
|
||||
return secretsById[item.id] || item.secret || '';
|
||||
}
|
||||
|
||||
function maskApiKey(secret: string) {
|
||||
if (secret.length <= 18) return secret;
|
||||
return `${secret.slice(0, 12)}...${secret.slice(-4)}`;
|
||||
}
|
||||
|
||||
function permissionSummaryText(summary: ReturnType<typeof countAccessPermissionRules>) {
|
||||
const allow = summary.allow.platforms + summary.allow.models;
|
||||
const deny = summary.deny.platforms + summary.deny.models;
|
||||
if (allow === 0 && deny === 0) return '未配置';
|
||||
return `允许 ${allow} / 拒绝 ${deny}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function platformsForPermissionTree(models: PlatformModel[]): IntegrationPlatform[] {
|
||||
const byPlatform = new Map<string, IntegrationPlatform>();
|
||||
for (const model of models) {
|
||||
if (byPlatform.has(model.platformId)) continue;
|
||||
const name = model.platformName || model.provider || model.platformId;
|
||||
byPlatform.set(model.platformId, {
|
||||
id: model.platformId,
|
||||
provider: model.provider || '',
|
||||
platformKey: model.platformId,
|
||||
name,
|
||||
authType: '',
|
||||
status: 'enabled',
|
||||
priority: 100,
|
||||
defaultPricingMode: 'inherit',
|
||||
defaultDiscountFactor: 1,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
});
|
||||
}
|
||||
return Array.from(byPlatform.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type {
|
||||
GatewayAccessEffect,
|
||||
GatewayAccessRule,
|
||||
GatewayAccessRuleBatchRequest,
|
||||
GatewayAccessRuleResourceRequest,
|
||||
GatewayAccessResourceType,
|
||||
GatewayAccessSubjectType,
|
||||
IntegrationPlatform,
|
||||
PlatformModel,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, Input } from '../../components/ui';
|
||||
import type { LoadState } from '../../types';
|
||||
|
||||
type Effect = Extract<GatewayAccessEffect, 'allow' | 'deny'>;
|
||||
type ResourceType = Extract<GatewayAccessResourceType, 'platform' | 'platform_model'>;
|
||||
type ResourceKey = `${ResourceType}:${string}`;
|
||||
|
||||
type PlatformNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
models: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function AccessPermissionEditor(props: {
|
||||
accessRules: GatewayAccessRule[];
|
||||
emptySubjectText?: string;
|
||||
metadataMode?: string;
|
||||
platformModels: PlatformModel[];
|
||||
platforms: IntegrationPlatform[];
|
||||
state: LoadState;
|
||||
subjectId: string;
|
||||
subjectType: Extract<GatewayAccessSubjectType, 'user_group' | 'api_key'>;
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
}) {
|
||||
const [allowSearch, setAllowSearch] = useState('');
|
||||
const [denySearch, setDenySearch] = useState('');
|
||||
const [allowExpanded, setAllowExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id)));
|
||||
const [denyExpanded, setDenyExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id)));
|
||||
const [localError, setLocalError] = useState('');
|
||||
|
||||
const platformTree = useMemo(() => buildPlatformTree(props.platforms, props.platformModels), [props.platformModels, props.platforms]);
|
||||
const subjectRules = useMemo(
|
||||
() => props.accessRules.filter((rule) => rule.subjectType === props.subjectType && rule.subjectId === props.subjectId && rule.status === 'active'),
|
||||
[props.accessRules, props.subjectId, props.subjectType],
|
||||
);
|
||||
const ruleByEffectAndResource = useMemo(() => buildRuleIndex(subjectRules), [subjectRules]);
|
||||
const allowTree = useMemo(() => filterTree(platformTree, allowSearch), [allowSearch, platformTree]);
|
||||
const denyTree = useMemo(() => filterTree(platformTree, denySearch), [denySearch, platformTree]);
|
||||
|
||||
useEffect(() => {
|
||||
setAllowExpanded((current) => mergeExpanded(current, props.platforms));
|
||||
setDenyExpanded((current) => mergeExpanded(current, props.platforms));
|
||||
}, [props.platforms]);
|
||||
|
||||
async function setPermission(effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) {
|
||||
const resourceKey = makeResourceKey(resourceType, resourceId);
|
||||
await applyPermissionBatch(effect, enabled ? [resourceKey] : [], enabled ? [] : [resourceKey]);
|
||||
}
|
||||
|
||||
async function setPlatformPermission(effect: Effect, platform: PlatformNode, enabled: boolean) {
|
||||
const keys = [
|
||||
makeResourceKey('platform', platform.id),
|
||||
...platform.models.map((model) => makeResourceKey('platform_model', model.id)),
|
||||
];
|
||||
await batchApply(effect, keys, enabled);
|
||||
}
|
||||
|
||||
async function selectAll(effect: Effect, nodes: PlatformNode[]) {
|
||||
await batchApply(effect, visibleResourceKeys(nodes), true);
|
||||
}
|
||||
|
||||
async function reverseVisible(effect: Effect, nodes: PlatformNode[]) {
|
||||
const keys = visibleResourceKeys(nodes);
|
||||
const upsertKeys: ResourceKey[] = [];
|
||||
const deleteKeys: ResourceKey[] = [];
|
||||
for (const key of keys) {
|
||||
const checked = Boolean(ruleByEffectAndResource.get(`${effect}:${key}`));
|
||||
if (checked) deleteKeys.push(key);
|
||||
else upsertKeys.push(key);
|
||||
}
|
||||
await applyPermissionBatch(effect, upsertKeys, deleteKeys);
|
||||
}
|
||||
|
||||
async function clearEffect(effect: Effect) {
|
||||
const keys = subjectRules
|
||||
.filter((rule) => rule.effect === effect)
|
||||
.map(resourceKeyFromRule)
|
||||
.filter((key): key is ResourceKey => Boolean(key));
|
||||
await applyPermissionBatch(effect, [], keys, '访问权限清空失败');
|
||||
}
|
||||
|
||||
async function batchApply(effect: Effect, resourceKeys: ResourceKey[], enabled: boolean) {
|
||||
await applyPermissionBatch(effect, enabled ? resourceKeys : [], enabled ? [] : resourceKeys);
|
||||
}
|
||||
|
||||
async function applyPermissionBatch(
|
||||
effect: Effect,
|
||||
upsertKeys: ResourceKey[],
|
||||
deleteKeys: ResourceKey[],
|
||||
errorMessage = '访问权限更新失败',
|
||||
) {
|
||||
if (!props.subjectId) return;
|
||||
const upsertResources = dedupeResourceKeys(upsertKeys).map((key) => createPermissionResource(effect, key, props.metadataMode));
|
||||
const deleteResources = dedupeResourceKeys(deleteKeys).map(resourceRequestFromKey);
|
||||
if (upsertResources.length === 0 && deleteResources.length === 0) return;
|
||||
setLocalError('');
|
||||
try {
|
||||
await props.onBatchAccessRules({
|
||||
subjectType: props.subjectType,
|
||||
subjectId: props.subjectId,
|
||||
effect,
|
||||
upsertResources,
|
||||
deleteResources,
|
||||
});
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
const allowSummary = countEffectRules(subjectRules, 'allow');
|
||||
const denySummary = countEffectRules(subjectRules, 'deny');
|
||||
|
||||
if (!props.subjectId) {
|
||||
return (
|
||||
<div className="emptyState">
|
||||
<strong>{props.emptySubjectText ?? '请选择维护对象'}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="accessEditorStack">
|
||||
{localError && <p className="formMessage">{localError}</p>}
|
||||
<section className="accessPermissionGrid">
|
||||
<PermissionTreePanel
|
||||
emptyText="暂无可维护的平台模型"
|
||||
effect="allow"
|
||||
expanded={allowExpanded}
|
||||
rules={ruleByEffectAndResource}
|
||||
search={allowSearch}
|
||||
state={props.state}
|
||||
summary={allowSummary}
|
||||
title="专属使用(平台/模型)"
|
||||
tree={allowTree}
|
||||
onClear={() => void clearEffect('allow')}
|
||||
onExpandAll={() => setAllowExpanded(new Set(platformTree.map((item) => item.id)))}
|
||||
onFoldAll={() => setAllowExpanded(new Set())}
|
||||
onReverse={() => void reverseVisible('allow', allowTree)}
|
||||
onSearchChange={setAllowSearch}
|
||||
onSelectAll={() => void selectAll('allow', allowTree)}
|
||||
onToggleExpanded={(platformId) => setAllowExpanded(toggleSet(allowExpanded, platformId))}
|
||||
onTogglePlatformPermission={setPlatformPermission}
|
||||
onTogglePermission={setPermission}
|
||||
/>
|
||||
<PermissionTreePanel
|
||||
emptyText="暂无可维护的平台模型"
|
||||
effect="deny"
|
||||
expanded={denyExpanded}
|
||||
rules={ruleByEffectAndResource}
|
||||
search={denySearch}
|
||||
state={props.state}
|
||||
summary={denySummary}
|
||||
title="不允许使用(平台/模型)"
|
||||
tree={denyTree}
|
||||
onClear={() => void clearEffect('deny')}
|
||||
onExpandAll={() => setDenyExpanded(new Set(platformTree.map((item) => item.id)))}
|
||||
onFoldAll={() => setDenyExpanded(new Set())}
|
||||
onReverse={() => void reverseVisible('deny', denyTree)}
|
||||
onSearchChange={setDenySearch}
|
||||
onSelectAll={() => void selectAll('deny', denyTree)}
|
||||
onToggleExpanded={(platformId) => setDenyExpanded(toggleSet(denyExpanded, platformId))}
|
||||
onTogglePlatformPermission={setPlatformPermission}
|
||||
onTogglePermission={setPermission}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionTreePanel(props: {
|
||||
effect: Effect;
|
||||
emptyText: string;
|
||||
expanded: Set<string>;
|
||||
rules: Map<string, GatewayAccessRule>;
|
||||
search: string;
|
||||
state: LoadState;
|
||||
summary: { platforms: number; models: number };
|
||||
title: string;
|
||||
tree: PlatformNode[];
|
||||
onClear: () => void;
|
||||
onExpandAll: () => void;
|
||||
onFoldAll: () => void;
|
||||
onReverse: () => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onToggleExpanded: (platformId: string) => void;
|
||||
onTogglePlatformPermission: (effect: Effect, platform: PlatformNode, enabled: boolean) => void;
|
||||
onTogglePermission: (effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className="accessPermissionPanel">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>{props.title}</CardTitle>
|
||||
<p className="mutedText">{props.summary.platforms} 个平台 / {props.summary.models} 个模型</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="accessTreeToolbar">
|
||||
<Input size="sm" value={props.search} placeholder="筛选平台/模型" onChange={(event) => props.onSearchChange(event.target.value)} />
|
||||
<Button type="button" variant="outline" size="sm" onClick={props.onExpandAll}>展开</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={props.onFoldAll}>折叠</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onSelectAll}>全选</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onReverse}>反选</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onClear}>清空</Button>
|
||||
</div>
|
||||
<div className="accessTreeBox">
|
||||
{props.tree.length ? props.tree.map((platform) => (
|
||||
<PlatformPermissionNode
|
||||
effect={props.effect}
|
||||
expanded={props.expanded.has(platform.id)}
|
||||
key={platform.id}
|
||||
platform={platform}
|
||||
rules={props.rules}
|
||||
onToggleExpanded={props.onToggleExpanded}
|
||||
onTogglePlatformPermission={props.onTogglePlatformPermission}
|
||||
onTogglePermission={props.onTogglePermission}
|
||||
/>
|
||||
)) : <span className="accessTreeEmpty">{props.emptyText}</span>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformPermissionNode(props: {
|
||||
effect: Effect;
|
||||
expanded: boolean;
|
||||
platform: PlatformNode;
|
||||
rules: Map<string, GatewayAccessRule>;
|
||||
onToggleExpanded: (platformId: string) => void;
|
||||
onTogglePlatformPermission: (effect: Effect, platform: PlatformNode, enabled: boolean) => void;
|
||||
onTogglePermission: (effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) => void;
|
||||
}) {
|
||||
const platformRuleKey = `${props.effect}:${makeResourceKey('platform', props.platform.id)}`;
|
||||
const checkedModels = props.platform.models.filter((model) => props.rules.has(`${props.effect}:${makeResourceKey('platform_model', model.id)}`)).length;
|
||||
const platformChecked = props.rules.has(platformRuleKey);
|
||||
const platformState = platformChecked ? true : checkedModels > 0 ? 'indeterminate' : false;
|
||||
return (
|
||||
<div className="accessTreeNode">
|
||||
<div className="accessTreeRow">
|
||||
<button type="button" className="accessTreeExpand" onClick={() => props.onToggleExpanded(props.platform.id)}>
|
||||
{props.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<Checkbox
|
||||
checked={platformState}
|
||||
onCheckedChange={(checked) => props.onTogglePlatformPermission(props.effect, props.platform, checked === true)}
|
||||
/>
|
||||
<span title={props.platform.subtitle || props.platform.name}>{props.platform.name}</span>
|
||||
{checkedModels > 0 && <Badge variant="secondary">{checkedModels}</Badge>}
|
||||
</div>
|
||||
{props.expanded && props.platform.models.length > 0 && (
|
||||
<div className="accessTreeChildren">
|
||||
{props.platform.models.map((model) => {
|
||||
const modelKey = `${props.effect}:${makeResourceKey('platform_model', model.id)}`;
|
||||
return (
|
||||
<label className="accessTreeRow accessTreeModel" key={model.id}>
|
||||
<span />
|
||||
<Checkbox
|
||||
checked={props.rules.has(modelKey)}
|
||||
onCheckedChange={(checked) => props.onTogglePermission(props.effect, 'platform_model', model.id, checked === true)}
|
||||
/>
|
||||
<span title={model.subtitle || model.name}>{model.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPlatformTree(platforms: IntegrationPlatform[], platformModels: PlatformModel[]): PlatformNode[] {
|
||||
const modelsByPlatform = new Map<string, PlatformModel[]>();
|
||||
for (const model of platformModels) {
|
||||
const current = modelsByPlatform.get(model.platformId) ?? [];
|
||||
current.push(model);
|
||||
modelsByPlatform.set(model.platformId, current);
|
||||
}
|
||||
return platforms.map((platform) => ({
|
||||
id: platform.id,
|
||||
name: platform.internalName || platform.name,
|
||||
subtitle: `${platform.provider} / ${platform.platformKey}`,
|
||||
models: (modelsByPlatform.get(platform.id) ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => modelLabel(a).localeCompare(modelLabel(b)))
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
name: modelLabel(model),
|
||||
subtitle: `${model.modelType} / ${model.modelName}`,
|
||||
})),
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function filterTree(tree: PlatformNode[], keyword: string): PlatformNode[] {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
if (!normalized) return tree;
|
||||
return tree.flatMap((platform) => {
|
||||
const platformMatched = `${platform.name} ${platform.subtitle}`.toLowerCase().includes(normalized);
|
||||
const models = platformMatched
|
||||
? platform.models
|
||||
: platform.models.filter((model) => `${model.name} ${model.subtitle}`.toLowerCase().includes(normalized));
|
||||
return platformMatched || models.length ? [{ ...platform, models }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function buildRuleIndex(rules: GatewayAccessRule[]) {
|
||||
const index = new Map<string, GatewayAccessRule>();
|
||||
for (const rule of rules) {
|
||||
if (rule.resourceType !== 'platform' && rule.resourceType !== 'platform_model') continue;
|
||||
if (rule.effect !== 'allow' && rule.effect !== 'deny') continue;
|
||||
index.set(`${rule.effect}:${makeResourceKey(rule.resourceType, rule.resourceId)}`, rule);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function countAccessPermissionRules(
|
||||
rules: GatewayAccessRule[],
|
||||
subjectType: Extract<GatewayAccessSubjectType, 'user_group' | 'api_key'>,
|
||||
subjectId: string,
|
||||
) {
|
||||
const subjectRules = rules.filter((rule) => rule.subjectType === subjectType && rule.subjectId === subjectId && rule.status === 'active');
|
||||
return {
|
||||
allow: countEffectRules(subjectRules, 'allow'),
|
||||
deny: countEffectRules(subjectRules, 'deny'),
|
||||
};
|
||||
}
|
||||
|
||||
function countEffectRules(rules: GatewayAccessRule[], effect: Effect) {
|
||||
return rules.reduce((summary, rule) => {
|
||||
if (rule.effect !== effect) return summary;
|
||||
if (rule.resourceType === 'platform') summary.platforms += 1;
|
||||
if (rule.resourceType === 'platform_model') summary.models += 1;
|
||||
return summary;
|
||||
}, { platforms: 0, models: 0 });
|
||||
}
|
||||
|
||||
function visibleResourceKeys(nodes: PlatformNode[]): ResourceKey[] {
|
||||
return nodes.flatMap((platform) => [
|
||||
makeResourceKey('platform', platform.id),
|
||||
...platform.models.map((model) => makeResourceKey('platform_model', model.id)),
|
||||
]);
|
||||
}
|
||||
|
||||
function createPermissionResource(
|
||||
effect: Effect,
|
||||
resourceKey: ResourceKey,
|
||||
metadataMode = 'access_permission_tree',
|
||||
): GatewayAccessRuleResourceRequest {
|
||||
const [resourceType, resourceId] = splitResourceKey(resourceKey);
|
||||
return {
|
||||
resourceType,
|
||||
resourceId,
|
||||
priority: effect === 'deny' ? 10 : 100,
|
||||
minPermissionLevel: 0,
|
||||
conditions: {},
|
||||
metadata: { uiMode: metadataMode },
|
||||
status: 'active',
|
||||
};
|
||||
}
|
||||
|
||||
function resourceRequestFromKey(resourceKey: ResourceKey): GatewayAccessRuleResourceRequest {
|
||||
const [resourceType, resourceId] = splitResourceKey(resourceKey);
|
||||
return { resourceType, resourceId };
|
||||
}
|
||||
|
||||
function resourceKeyFromRule(rule: GatewayAccessRule): ResourceKey | undefined {
|
||||
if (rule.resourceType !== 'platform' && rule.resourceType !== 'platform_model') return undefined;
|
||||
return makeResourceKey(rule.resourceType, rule.resourceId);
|
||||
}
|
||||
|
||||
function dedupeResourceKeys(keys: ResourceKey[]) {
|
||||
return Array.from(new Set(keys.filter(Boolean)));
|
||||
}
|
||||
|
||||
function mergeExpanded(current: Set<string>, platforms: IntegrationPlatform[]) {
|
||||
if (current.size > 0) return current;
|
||||
return new Set(platforms.map((item) => item.id));
|
||||
}
|
||||
|
||||
function toggleSet(set: Set<string>, value: string) {
|
||||
const next = new Set(set);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return next;
|
||||
}
|
||||
|
||||
function makeResourceKey(resourceType: ResourceType, resourceId: string): ResourceKey {
|
||||
return `${resourceType}:${resourceId}`;
|
||||
}
|
||||
|
||||
function splitResourceKey(key: ResourceKey): [ResourceType, string] {
|
||||
const [resourceType, ...rest] = key.split(':');
|
||||
return [resourceType as ResourceType, rest.join(':')];
|
||||
}
|
||||
|
||||
function modelLabel(model: PlatformModel) {
|
||||
return model.displayName || model.modelAlias || model.modelName;
|
||||
}
|
||||
@@ -1,33 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, ShieldCheck } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import type {
|
||||
BaseModelCatalogItem,
|
||||
GatewayAccessEffect,
|
||||
GatewayAccessRuleBatchRequest,
|
||||
GatewayAccessRuleResourceRequest,
|
||||
GatewayAccessResourceType,
|
||||
GatewayAccessRule,
|
||||
GatewayAccessRuleBatchRequest,
|
||||
IntegrationPlatform,
|
||||
PlatformModel,
|
||||
UserGroup,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, Input, Label, Select } from '../../components/ui';
|
||||
import { Badge, Card, CardContent, CardHeader, CardTitle, Label, Select } from '../../components/ui';
|
||||
import type { LoadState } from '../../types';
|
||||
|
||||
type Effect = Extract<GatewayAccessEffect, 'allow' | 'deny'>;
|
||||
type ResourceType = Extract<GatewayAccessResourceType, 'platform' | 'platform_model'>;
|
||||
type ResourceKey = `${ResourceType}:${string}`;
|
||||
|
||||
type PlatformNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
models: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
}>;
|
||||
};
|
||||
import { AccessPermissionEditor } from './AccessPermissionEditor';
|
||||
|
||||
export function AccessRulesPanel(props: {
|
||||
accessRules: GatewayAccessRule[];
|
||||
@@ -40,20 +23,6 @@ export function AccessRulesPanel(props: {
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
}) {
|
||||
const [selectedGroupId, setSelectedGroupId] = useState(props.userGroups[0]?.id ?? '');
|
||||
const [allowSearch, setAllowSearch] = useState('');
|
||||
const [denySearch, setDenySearch] = useState('');
|
||||
const [allowExpanded, setAllowExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id)));
|
||||
const [denyExpanded, setDenyExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id)));
|
||||
const [localError, setLocalError] = useState('');
|
||||
|
||||
const platformTree = useMemo(() => buildPlatformTree(props.platforms, props.platformModels), [props.platformModels, props.platforms]);
|
||||
const selectedGroupRules = useMemo(
|
||||
() => props.accessRules.filter((rule) => rule.subjectType === 'user_group' && rule.subjectId === selectedGroupId && rule.status === 'active'),
|
||||
[props.accessRules, selectedGroupId],
|
||||
);
|
||||
const ruleByEffectAndResource = useMemo(() => buildRuleIndex(selectedGroupRules), [selectedGroupRules]);
|
||||
const allowTree = useMemo(() => filterTree(platformTree, allowSearch), [allowSearch, platformTree]);
|
||||
const denyTree = useMemo(() => filterTree(platformTree, denySearch), [denySearch, platformTree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGroupId && props.userGroups[0]?.id) {
|
||||
@@ -61,79 +30,6 @@ export function AccessRulesPanel(props: {
|
||||
}
|
||||
}, [props.userGroups, selectedGroupId]);
|
||||
|
||||
useEffect(() => {
|
||||
setAllowExpanded((current) => mergeExpanded(current, props.platforms));
|
||||
setDenyExpanded((current) => mergeExpanded(current, props.platforms));
|
||||
}, [props.platforms]);
|
||||
|
||||
async function setPermission(effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) {
|
||||
const resourceKey = makeResourceKey(resourceType, resourceId);
|
||||
await applyPermissionBatch(effect, enabled ? [resourceKey] : [], enabled ? [] : [resourceKey]);
|
||||
}
|
||||
|
||||
async function setPlatformPermission(effect: Effect, platform: PlatformNode, enabled: boolean) {
|
||||
const keys = [
|
||||
makeResourceKey('platform', platform.id),
|
||||
...platform.models.map((model) => makeResourceKey('platform_model', model.id)),
|
||||
];
|
||||
await batchApply(effect, keys, enabled);
|
||||
}
|
||||
|
||||
async function selectAll(effect: Effect, nodes: PlatformNode[]) {
|
||||
await batchApply(effect, visibleResourceKeys(nodes), true);
|
||||
}
|
||||
|
||||
async function reverseVisible(effect: Effect, nodes: PlatformNode[]) {
|
||||
const keys = visibleResourceKeys(nodes);
|
||||
const upsertKeys: ResourceKey[] = [];
|
||||
const deleteKeys: ResourceKey[] = [];
|
||||
for (const key of keys) {
|
||||
const checked = Boolean(ruleByEffectAndResource.get(`${effect}:${key}`));
|
||||
if (checked) deleteKeys.push(key);
|
||||
else upsertKeys.push(key);
|
||||
}
|
||||
await applyPermissionBatch(effect, upsertKeys, deleteKeys);
|
||||
}
|
||||
|
||||
async function clearEffect(effect: Effect) {
|
||||
const keys = selectedGroupRules
|
||||
.filter((rule) => rule.effect === effect)
|
||||
.map(resourceKeyFromRule)
|
||||
.filter((key): key is ResourceKey => Boolean(key));
|
||||
await applyPermissionBatch(effect, [], keys, '访问权限清空失败');
|
||||
}
|
||||
|
||||
async function batchApply(effect: Effect, resourceKeys: ResourceKey[], enabled: boolean) {
|
||||
await applyPermissionBatch(effect, enabled ? resourceKeys : [], enabled ? [] : resourceKeys);
|
||||
}
|
||||
|
||||
async function applyPermissionBatch(
|
||||
effect: Effect,
|
||||
upsertKeys: ResourceKey[],
|
||||
deleteKeys: ResourceKey[],
|
||||
errorMessage = '访问权限更新失败',
|
||||
) {
|
||||
if (!selectedGroupId) return;
|
||||
const upsertResources = dedupeResourceKeys(upsertKeys).map((key) => createPermissionResource(effect, key));
|
||||
const deleteResources = dedupeResourceKeys(deleteKeys).map(resourceRequestFromKey);
|
||||
if (upsertResources.length === 0 && deleteResources.length === 0) return;
|
||||
setLocalError('');
|
||||
try {
|
||||
await props.onBatchAccessRules({
|
||||
subjectType: 'user_group',
|
||||
subjectId: selectedGroupId,
|
||||
effect,
|
||||
upsertResources,
|
||||
deleteResources,
|
||||
});
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
const allowSummary = countEffectRules(selectedGroupRules, 'allow');
|
||||
const denySummary = countEffectRules(selectedGroupRules, 'deny');
|
||||
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<Card>
|
||||
@@ -145,7 +41,7 @@ export function AccessRulesPanel(props: {
|
||||
<Badge variant="secondary">{props.accessRules.length} 条规则</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
|
||||
{props.message && <p className="formMessage">{props.message}</p>}
|
||||
<div className="accessGroupToolbar">
|
||||
<Label>
|
||||
用户组
|
||||
@@ -162,48 +58,16 @@ export function AccessRulesPanel(props: {
|
||||
</Card>
|
||||
|
||||
{selectedGroupId ? (
|
||||
<section className="accessPermissionGrid">
|
||||
<PermissionTreePanel
|
||||
emptyText="暂无可维护的平台模型"
|
||||
effect="allow"
|
||||
expanded={allowExpanded}
|
||||
rules={ruleByEffectAndResource}
|
||||
search={allowSearch}
|
||||
state={props.state}
|
||||
summary={allowSummary}
|
||||
title="专属使用(平台/模型)"
|
||||
tree={allowTree}
|
||||
onClear={() => void clearEffect('allow')}
|
||||
onExpandAll={() => setAllowExpanded(new Set(platformTree.map((item) => item.id)))}
|
||||
onFoldAll={() => setAllowExpanded(new Set())}
|
||||
onReverse={() => void reverseVisible('allow', allowTree)}
|
||||
onSearchChange={setAllowSearch}
|
||||
onSelectAll={() => void selectAll('allow', allowTree)}
|
||||
onToggleExpanded={(platformId) => setAllowExpanded(toggleSet(allowExpanded, platformId))}
|
||||
onTogglePlatformPermission={setPlatformPermission}
|
||||
onTogglePermission={setPermission}
|
||||
/>
|
||||
<PermissionTreePanel
|
||||
emptyText="暂无可维护的平台模型"
|
||||
effect="deny"
|
||||
expanded={denyExpanded}
|
||||
rules={ruleByEffectAndResource}
|
||||
search={denySearch}
|
||||
state={props.state}
|
||||
summary={denySummary}
|
||||
title="不允许使用(平台/模型)"
|
||||
tree={denyTree}
|
||||
onClear={() => void clearEffect('deny')}
|
||||
onExpandAll={() => setDenyExpanded(new Set(platformTree.map((item) => item.id)))}
|
||||
onFoldAll={() => setDenyExpanded(new Set())}
|
||||
onReverse={() => void reverseVisible('deny', denyTree)}
|
||||
onSearchChange={setDenySearch}
|
||||
onSelectAll={() => void selectAll('deny', denyTree)}
|
||||
onToggleExpanded={(platformId) => setDenyExpanded(toggleSet(denyExpanded, platformId))}
|
||||
onTogglePlatformPermission={setPlatformPermission}
|
||||
onTogglePermission={setPermission}
|
||||
/>
|
||||
</section>
|
||||
<AccessPermissionEditor
|
||||
accessRules={props.accessRules}
|
||||
metadataMode="user_group_permission_tree"
|
||||
platformModels={props.platformModels}
|
||||
platforms={props.platforms}
|
||||
state={props.state}
|
||||
subjectId={selectedGroupId}
|
||||
subjectType="user_group"
|
||||
onBatchAccessRules={props.onBatchAccessRules}
|
||||
/>
|
||||
) : (
|
||||
<div className="emptyState">
|
||||
<strong>暂无用户组</strong>
|
||||
@@ -213,221 +77,3 @@ export function AccessRulesPanel(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionTreePanel(props: {
|
||||
effect: Effect;
|
||||
emptyText: string;
|
||||
expanded: Set<string>;
|
||||
rules: Map<string, GatewayAccessRule>;
|
||||
search: string;
|
||||
state: LoadState;
|
||||
summary: { platforms: number; models: number };
|
||||
title: string;
|
||||
tree: PlatformNode[];
|
||||
onClear: () => void;
|
||||
onExpandAll: () => void;
|
||||
onFoldAll: () => void;
|
||||
onReverse: () => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onToggleExpanded: (platformId: string) => void;
|
||||
onTogglePlatformPermission: (effect: Effect, platform: PlatformNode, enabled: boolean) => void;
|
||||
onTogglePermission: (effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className="accessPermissionPanel">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>{props.title}</CardTitle>
|
||||
<p className="mutedText">{props.summary.platforms} 个平台 / {props.summary.models} 个模型</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="accessTreeToolbar">
|
||||
<Input size="sm" value={props.search} placeholder="筛选平台/模型" onChange={(event) => props.onSearchChange(event.target.value)} />
|
||||
<Button type="button" variant="outline" size="sm" onClick={props.onExpandAll}>展开</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={props.onFoldAll}>折叠</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onSelectAll}>全选</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onReverse}>反选</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onClear}>清空</Button>
|
||||
</div>
|
||||
<div className="accessTreeBox">
|
||||
{props.tree.length ? props.tree.map((platform) => (
|
||||
<PlatformPermissionNode
|
||||
effect={props.effect}
|
||||
expanded={props.expanded.has(platform.id)}
|
||||
key={platform.id}
|
||||
platform={platform}
|
||||
rules={props.rules}
|
||||
onToggleExpanded={props.onToggleExpanded}
|
||||
onTogglePlatformPermission={props.onTogglePlatformPermission}
|
||||
onTogglePermission={props.onTogglePermission}
|
||||
/>
|
||||
)) : <span className="accessTreeEmpty">{props.emptyText}</span>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformPermissionNode(props: {
|
||||
effect: Effect;
|
||||
expanded: boolean;
|
||||
platform: PlatformNode;
|
||||
rules: Map<string, GatewayAccessRule>;
|
||||
onToggleExpanded: (platformId: string) => void;
|
||||
onTogglePlatformPermission: (effect: Effect, platform: PlatformNode, enabled: boolean) => void;
|
||||
onTogglePermission: (effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) => void;
|
||||
}) {
|
||||
const platformRuleKey = `${props.effect}:${makeResourceKey('platform', props.platform.id)}`;
|
||||
const checkedModels = props.platform.models.filter((model) => props.rules.has(`${props.effect}:${makeResourceKey('platform_model', model.id)}`)).length;
|
||||
const platformChecked = props.rules.has(platformRuleKey);
|
||||
const platformState = platformChecked ? true : checkedModels > 0 ? 'indeterminate' : false;
|
||||
return (
|
||||
<div className="accessTreeNode">
|
||||
<div className="accessTreeRow">
|
||||
<button type="button" className="accessTreeExpand" onClick={() => props.onToggleExpanded(props.platform.id)}>
|
||||
{props.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<Checkbox
|
||||
checked={platformState}
|
||||
onCheckedChange={(checked) => props.onTogglePlatformPermission(props.effect, props.platform, checked === true)}
|
||||
/>
|
||||
<span title={props.platform.subtitle || props.platform.name}>{props.platform.name}</span>
|
||||
{checkedModels > 0 && <Badge variant="secondary">{checkedModels}</Badge>}
|
||||
</div>
|
||||
{props.expanded && props.platform.models.length > 0 && (
|
||||
<div className="accessTreeChildren">
|
||||
{props.platform.models.map((model) => {
|
||||
const modelKey = `${props.effect}:${makeResourceKey('platform_model', model.id)}`;
|
||||
return (
|
||||
<label className="accessTreeRow accessTreeModel" key={model.id}>
|
||||
<span />
|
||||
<Checkbox
|
||||
checked={props.rules.has(modelKey)}
|
||||
onCheckedChange={(checked) => props.onTogglePermission(props.effect, 'platform_model', model.id, checked === true)}
|
||||
/>
|
||||
<span title={model.subtitle || model.name}>{model.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPlatformTree(platforms: IntegrationPlatform[], platformModels: PlatformModel[]): PlatformNode[] {
|
||||
const modelsByPlatform = new Map<string, PlatformModel[]>();
|
||||
for (const model of platformModels) {
|
||||
const current = modelsByPlatform.get(model.platformId) ?? [];
|
||||
current.push(model);
|
||||
modelsByPlatform.set(model.platformId, current);
|
||||
}
|
||||
return platforms.map((platform) => ({
|
||||
id: platform.id,
|
||||
name: platform.internalName || platform.name,
|
||||
subtitle: `${platform.provider} / ${platform.platformKey}`,
|
||||
models: (modelsByPlatform.get(platform.id) ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => modelLabel(a).localeCompare(modelLabel(b)))
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
name: modelLabel(model),
|
||||
subtitle: `${model.modelType} / ${model.modelName}`,
|
||||
})),
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function filterTree(tree: PlatformNode[], keyword: string): PlatformNode[] {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
if (!normalized) return tree;
|
||||
return tree.flatMap((platform) => {
|
||||
const platformMatched = `${platform.name} ${platform.subtitle}`.toLowerCase().includes(normalized);
|
||||
const models = platformMatched
|
||||
? platform.models
|
||||
: platform.models.filter((model) => `${model.name} ${model.subtitle}`.toLowerCase().includes(normalized));
|
||||
return platformMatched || models.length ? [{ ...platform, models }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function buildRuleIndex(rules: GatewayAccessRule[]) {
|
||||
const index = new Map<string, GatewayAccessRule>();
|
||||
for (const rule of rules) {
|
||||
if (rule.resourceType !== 'platform' && rule.resourceType !== 'platform_model') continue;
|
||||
if (rule.effect !== 'allow' && rule.effect !== 'deny') continue;
|
||||
index.set(`${rule.effect}:${makeResourceKey(rule.resourceType, rule.resourceId)}`, rule);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function countEffectRules(rules: GatewayAccessRule[], effect: Effect) {
|
||||
return rules.reduce((summary, rule) => {
|
||||
if (rule.effect !== effect) return summary;
|
||||
if (rule.resourceType === 'platform') summary.platforms += 1;
|
||||
if (rule.resourceType === 'platform_model') summary.models += 1;
|
||||
return summary;
|
||||
}, { platforms: 0, models: 0 });
|
||||
}
|
||||
|
||||
function visibleResourceKeys(nodes: PlatformNode[]): ResourceKey[] {
|
||||
return nodes.flatMap((platform) => [
|
||||
makeResourceKey('platform', platform.id),
|
||||
...platform.models.map((model) => makeResourceKey('platform_model', model.id)),
|
||||
]);
|
||||
}
|
||||
|
||||
function createPermissionResource(
|
||||
effect: Effect,
|
||||
resourceKey: ResourceKey,
|
||||
): GatewayAccessRuleResourceRequest {
|
||||
const [resourceType, resourceId] = splitResourceKey(resourceKey);
|
||||
return {
|
||||
resourceType,
|
||||
resourceId,
|
||||
priority: effect === 'deny' ? 10 : 100,
|
||||
minPermissionLevel: 0,
|
||||
conditions: {},
|
||||
metadata: { uiMode: 'user_group_permission_tree' },
|
||||
status: 'active',
|
||||
};
|
||||
}
|
||||
|
||||
function resourceRequestFromKey(resourceKey: ResourceKey): GatewayAccessRuleResourceRequest {
|
||||
const [resourceType, resourceId] = splitResourceKey(resourceKey);
|
||||
return { resourceType, resourceId };
|
||||
}
|
||||
|
||||
function resourceKeyFromRule(rule: GatewayAccessRule): ResourceKey | undefined {
|
||||
if (rule.resourceType !== 'platform' && rule.resourceType !== 'platform_model') return undefined;
|
||||
return makeResourceKey(rule.resourceType, rule.resourceId);
|
||||
}
|
||||
|
||||
function dedupeResourceKeys(keys: ResourceKey[]) {
|
||||
return Array.from(new Set(keys.filter(Boolean)));
|
||||
}
|
||||
|
||||
function mergeExpanded(current: Set<string>, platforms: IntegrationPlatform[]) {
|
||||
if (current.size > 0) return current;
|
||||
return new Set(platforms.map((item) => item.id));
|
||||
}
|
||||
|
||||
function toggleSet(set: Set<string>, value: string) {
|
||||
const next = new Set(set);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return next;
|
||||
}
|
||||
|
||||
function makeResourceKey(resourceType: ResourceType, resourceId: string): ResourceKey {
|
||||
return `${resourceType}:${resourceId}`;
|
||||
}
|
||||
|
||||
function splitResourceKey(key: ResourceKey): [ResourceType, string] {
|
||||
const [resourceType, ...rest] = key.split(':');
|
||||
return [resourceType as ResourceType, rest.join(':')];
|
||||
}
|
||||
|
||||
function modelLabel(model: PlatformModel) {
|
||||
return model.displayName || model.modelAlias || model.modelName;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user