feat: refine api key permissions and admin routes

This commit is contained in:
2026-05-10 23:22:26 +08:00
parent 0fc23d7eb8
commit d86651ff55
23 changed files with 1683 additions and 532 deletions
+226 -36
View File
@@ -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;
}
+16 -370
View File
@@ -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;
}