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
+3
View File
@@ -15,6 +15,7 @@
"@easyai-ai-gateway/contracts": "workspace:*",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@streamdown/cjk": "^1.0.3",
@@ -24,9 +25,11 @@
"@vitejs/plugin-react": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"katex": "^0.16.45",
"lucide-react": "^1.14.0",
"react": "^19.0.0",
"react-day-picker": "^10.0.0",
"react-dom": "^19.0.0",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.5.0",
+66 -4
View File
@@ -22,6 +22,7 @@ import type {
} from '@easyai-ai-gateway/contracts';
import {
batchAccessRules,
batchApiKeyAccessRules,
createAccessRule,
createApiKey,
createGatewayUser,
@@ -29,6 +30,7 @@ import {
createTenant,
createUserGroup,
deleteAccessRule,
deleteApiKey,
deleteGatewayUser,
deletePlatform,
deleteTenant,
@@ -36,6 +38,7 @@ import {
getHealth,
getTask,
listAccessRules,
listApiKeyAccessRules,
listApiKeys,
listBaseModels,
listCatalogProviders,
@@ -148,7 +151,7 @@ export function App() {
const [users, setUsers] = useState<GatewayUser[]>([]);
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
const [apiKeys, setApiKeys] = useState<GatewayApiKey[]>([]);
const [apiKeyForm, setApiKeyForm] = useState<ApiKeyForm>({ name: 'Local smoke key' });
const [apiKeyForm, setApiKeyForm] = useState<ApiKeyForm>({ name: 'Local smoke key', expiresAt: '' });
const [apiKeySecret, setApiKeySecret] = useState('');
const [apiKeySecretsById, setApiKeySecretsById] = useState<Record<string, string>>({});
const [selectedPlaygroundApiKeyId, setSelectedPlaygroundApiKeyId] = useState('');
@@ -234,6 +237,10 @@ export function App() {
await ensureRouteData(nextToken, true);
}
function invalidateDataKeys(...keys: DataKey[]) {
keys.forEach((key) => loadedDataKeysRef.current.delete(key));
}
async function ensureRouteData(nextToken = token, force = false) {
await ensureData(dataKeysForRoute(activePage, adminSection, workspaceSection, Boolean(nextToken)), nextToken, force);
}
@@ -321,7 +328,9 @@ export function App() {
setUserGroups((await listUserGroups(nextToken)).items);
return;
case 'accessRules':
setAccessRules((await listAccessRules(nextToken)).items);
setAccessRules((await (activePage === 'workspace' && workspaceSection === 'apiKeys'
? listApiKeyAccessRules(nextToken)
: listAccessRules(nextToken))).items);
return;
case 'apiKeys':
setApiKeys((await listApiKeys(nextToken)).items);
@@ -369,16 +378,22 @@ export function App() {
setCoreState('loading');
setCoreMessage('');
try {
const response = await createApiKey(token, { name: apiKeyForm.name, scopes: ['chat', 'image', 'video'] });
const response = await createApiKey(token, {
name: apiKeyForm.name,
scopes: ['chat', 'image', 'video'],
expiresAt: apiKeyForm.expiresAt ? new Date(apiKeyForm.expiresAt).toISOString() : undefined,
});
setApiKeySecret(response.secret);
setApiKeySecretsById((current) => ({ ...current, [response.apiKey.id]: response.secret }));
setSelectedPlaygroundApiKeyId(response.apiKey.id);
setApiKeys((current) => [response.apiKey, ...current.filter((item) => item.id !== response.apiKey.id)]);
setApiKeyForm({ name: '', expiresAt: '' });
setCoreState('ready');
setCoreMessage('API Key 已创建,secret 仅展示一次。');
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '创建 API Key 失败');
throw err;
}
}
@@ -461,6 +476,7 @@ export function App() {
try {
const item = userId ? await updateGatewayUser(token, userId, input) : await createGatewayUser(token, input);
setUsers((current) => [item, ...current.filter((user) => user.id !== item.id)]);
invalidateDataKeys('playgroundModels');
setCoreState('ready');
setCoreMessage(userId ? '用户已更新。' : '用户已创建。');
} catch (err) {
@@ -508,6 +524,7 @@ export function App() {
setUserGroups((current) => current.filter((group) => group.id !== groupId));
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant));
setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user));
invalidateDataKeys('playgroundModels');
setCoreState('ready');
setCoreMessage('用户组已删除。');
} catch (err) {
@@ -517,12 +534,35 @@ export function App() {
}
}
async function removeAPIKey(apiKeyId: string) {
setCoreState('loading');
setCoreMessage('');
try {
await deleteApiKey(token, apiKeyId);
setApiKeys((current) => current.filter((item) => item.id !== apiKeyId));
setAccessRules((current) => current.filter((rule) => !(rule.subjectType === 'api_key' && rule.subjectId === apiKeyId)));
setApiKeySecretsById((current) => {
const next = { ...current };
delete next[apiKeyId];
return next;
});
if (selectedPlaygroundApiKeyId === apiKeyId) setSelectedPlaygroundApiKeyId('');
setCoreState('ready');
setCoreMessage('API Key 已删除,对应权限策略已同步清理。');
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '删除 API Key 失败');
throw err;
}
}
async function saveAccessRule(input: GatewayAccessRuleUpsertRequest, ruleId?: string) {
setCoreState('loading');
setCoreMessage('');
try {
const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input);
setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
invalidateDataKeys('playgroundModels');
setCoreState('ready');
setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。');
} catch (err) {
@@ -538,6 +578,7 @@ export function App() {
try {
await deleteAccessRule(token, ruleId);
setAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
invalidateDataKeys('playgroundModels');
setCoreState('ready');
setCoreMessage('访问权限规则已删除。');
} catch (err) {
@@ -553,6 +594,7 @@ export function App() {
try {
const response = await batchAccessRules(token, input);
setAccessRules(response.items);
invalidateDataKeys('playgroundModels');
setCoreState('ready');
setCoreMessage('访问权限已更新。');
} catch (err) {
@@ -562,6 +604,21 @@ export function App() {
}
}
async function batchSaveAPIKeyAccessRules(input: GatewayAccessRuleBatchRequest) {
setCoreState('loading');
setCoreMessage('');
try {
const response = await batchApiKeyAccessRules(token, input);
setAccessRules(response.items);
setCoreState('ready');
setCoreMessage('API Key 权限已更新。');
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '批量更新 API Key 权限失败');
throw err;
}
}
async function submitTask(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const credential = apiKeySecret || token;
@@ -695,9 +752,14 @@ export function App() {
<WorkspacePage
apiKeyForm={apiKeyForm}
apiKeySecret={apiKeySecret}
apiKeySecretsById={apiKeySecretsById}
apiKeyPolicyModels={playgroundModels}
data={data}
message={coreMessage}
section={workspaceSection}
state={coreState}
onBatchAccessRules={batchSaveAPIKeyAccessRules}
onDeleteApiKey={removeAPIKey}
onApiKeyFormChange={setApiKeyForm}
onSectionChange={navigateWorkspaceSection}
onSubmitApiKey={submitAPIKey}
@@ -861,7 +923,7 @@ function dataKeysForRoute(
if (activePage === 'workspace') {
if (workspaceSection === 'overview') return ['users', 'userGroups', 'apiKeys'];
if (workspaceSection === 'apiKeys') return ['apiKeys'];
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'playgroundModels'];
return [];
}
+66 -47
View File
@@ -65,15 +65,15 @@ export async function loginLocalAccount(input: { account: string; password: stri
}
export async function listPlatforms(token: string): Promise<ListResponse<IntegrationPlatform>> {
return request<ListResponse<IntegrationPlatform>>('/api/v1/platforms', { token });
return request<ListResponse<IntegrationPlatform>>('/api/admin/platforms', { token });
}
export async function listModels(token: string): Promise<ListResponse<PlatformModel>> {
return request<ListResponse<PlatformModel>>('/api/v1/models', { token });
return request<ListResponse<PlatformModel>>('/api/admin/models', { token });
}
export async function listPlayableModels(token: string): Promise<ListResponse<PlatformModel>> {
return request<ListResponse<PlatformModel>>('/api/v1/playground/models', { token });
return request<ListResponse<PlatformModel>>('/api/v1/models', { token });
}
export async function listPublicCatalogProviders(): Promise<ListResponse<CatalogProvider>> {
@@ -81,14 +81,14 @@ export async function listPublicCatalogProviders(): Promise<ListResponse<Catalog
}
export async function listCatalogProviders(token: string): Promise<ListResponse<CatalogProvider>> {
return request<ListResponse<CatalogProvider>>('/api/v1/catalog/providers', { token });
return request<ListResponse<CatalogProvider>>('/api/admin/catalog/providers', { token });
}
export async function createCatalogProvider(
token: string,
input: CatalogProviderUpsertRequest,
): Promise<CatalogProvider> {
return request<CatalogProvider>('/api/v1/catalog/providers', {
return request<CatalogProvider>('/api/admin/catalog/providers', {
body: input,
method: 'POST',
token,
@@ -100,7 +100,7 @@ export async function updateCatalogProvider(
providerId: string,
input: CatalogProviderUpsertRequest,
): Promise<CatalogProvider> {
return request<CatalogProvider>(`/api/v1/catalog/providers/${providerId}`, {
return request<CatalogProvider>(`/api/admin/catalog/providers/${providerId}`, {
body: input,
method: 'PATCH',
token,
@@ -108,7 +108,7 @@ export async function updateCatalogProvider(
}
export async function deleteCatalogProvider(token: string, providerId: string): Promise<void> {
await request<void>(`/api/v1/catalog/providers/${providerId}`, {
await request<void>(`/api/admin/catalog/providers/${providerId}`, {
method: 'DELETE',
token,
});
@@ -119,11 +119,11 @@ export async function listPublicBaseModels(): Promise<ListResponse<BaseModelCata
}
export async function listBaseModels(token: string): Promise<ListResponse<BaseModelCatalogItem>> {
return request<ListResponse<BaseModelCatalogItem>>('/api/v1/catalog/base-models', { token });
return request<ListResponse<BaseModelCatalogItem>>('/api/admin/catalog/base-models', { token });
}
export async function createBaseModel(token: string, input: BaseModelUpsertRequest): Promise<BaseModelCatalogItem> {
return request<BaseModelCatalogItem>('/api/v1/catalog/base-models', {
return request<BaseModelCatalogItem>('/api/admin/catalog/base-models', {
body: input,
method: 'POST',
token,
@@ -135,7 +135,7 @@ export async function updateBaseModel(
baseModelId: string,
input: BaseModelUpsertRequest,
): Promise<BaseModelCatalogItem> {
return request<BaseModelCatalogItem>(`/api/v1/catalog/base-models/${baseModelId}`, {
return request<BaseModelCatalogItem>(`/api/admin/catalog/base-models/${baseModelId}`, {
body: input,
method: 'PATCH',
token,
@@ -143,39 +143,39 @@ export async function updateBaseModel(
}
export async function resetBaseModel(token: string, baseModelId: string): Promise<BaseModelCatalogItem> {
return request<BaseModelCatalogItem>(`/api/v1/catalog/base-models/${baseModelId}/reset`, {
return request<BaseModelCatalogItem>(`/api/admin/catalog/base-models/${baseModelId}/reset`, {
method: 'POST',
token,
});
}
export async function resetAllBaseModels(token: string): Promise<ListResponse<BaseModelCatalogItem>> {
return request<ListResponse<BaseModelCatalogItem>>('/api/v1/catalog/base-models/reset-all', {
return request<ListResponse<BaseModelCatalogItem>>('/api/admin/catalog/base-models/reset-all', {
method: 'POST',
token,
});
}
export async function deleteBaseModel(token: string, baseModelId: string): Promise<void> {
await request<void>(`/api/v1/catalog/base-models/${baseModelId}`, {
await request<void>(`/api/admin/catalog/base-models/${baseModelId}`, {
method: 'DELETE',
token,
});
}
export async function listPricingRules(token: string): Promise<ListResponse<PricingRule>> {
return request<ListResponse<PricingRule>>('/api/v1/pricing/rules', { token });
return request<ListResponse<PricingRule>>('/api/admin/pricing/rules', { token });
}
export async function listPricingRuleSets(token: string): Promise<ListResponse<PricingRuleSet>> {
return request<ListResponse<PricingRuleSet>>('/api/v1/pricing/rule-sets', { token });
return request<ListResponse<PricingRuleSet>>('/api/admin/pricing/rule-sets', { token });
}
export async function createPricingRuleSet(
token: string,
input: PricingRuleSetUpsertRequest,
): Promise<PricingRuleSet> {
return request<PricingRuleSet>('/api/v1/pricing/rule-sets', {
return request<PricingRuleSet>('/api/admin/pricing/rule-sets', {
body: input,
method: 'POST',
token,
@@ -187,7 +187,7 @@ export async function updatePricingRuleSet(
ruleSetId: string,
input: PricingRuleSetUpsertRequest,
): Promise<PricingRuleSet> {
return request<PricingRuleSet>(`/api/v1/pricing/rule-sets/${ruleSetId}`, {
return request<PricingRuleSet>(`/api/admin/pricing/rule-sets/${ruleSetId}`, {
body: input,
method: 'PATCH',
token,
@@ -195,21 +195,21 @@ export async function updatePricingRuleSet(
}
export async function deletePricingRuleSet(token: string, ruleSetId: string): Promise<void> {
await request<void>(`/api/v1/pricing/rule-sets/${ruleSetId}`, {
await request<void>(`/api/admin/pricing/rule-sets/${ruleSetId}`, {
method: 'DELETE',
token,
});
}
export async function listRuntimePolicySets(token: string): Promise<ListResponse<RuntimePolicySet>> {
return request<ListResponse<RuntimePolicySet>>('/api/v1/runtime/policy-sets', { token });
return request<ListResponse<RuntimePolicySet>>('/api/admin/runtime/policy-sets', { token });
}
export async function createRuntimePolicySet(
token: string,
input: RuntimePolicySetUpsertRequest,
): Promise<RuntimePolicySet> {
return request<RuntimePolicySet>('/api/v1/runtime/policy-sets', {
return request<RuntimePolicySet>('/api/admin/runtime/policy-sets', {
body: input,
method: 'POST',
token,
@@ -221,7 +221,7 @@ export async function updateRuntimePolicySet(
policySetId: string,
input: RuntimePolicySetUpsertRequest,
): Promise<RuntimePolicySet> {
return request<RuntimePolicySet>(`/api/v1/runtime/policy-sets/${policySetId}`, {
return request<RuntimePolicySet>(`/api/admin/runtime/policy-sets/${policySetId}`, {
body: input,
method: 'PATCH',
token,
@@ -229,18 +229,18 @@ export async function updateRuntimePolicySet(
}
export async function deleteRuntimePolicySet(token: string, policySetId: string): Promise<void> {
await request<void>(`/api/v1/runtime/policy-sets/${policySetId}`, {
await request<void>(`/api/admin/runtime/policy-sets/${policySetId}`, {
method: 'DELETE',
token,
});
}
export async function listTenants(token: string): Promise<ListResponse<GatewayTenant>> {
return request<ListResponse<GatewayTenant>>('/api/v1/tenants', { token });
return request<ListResponse<GatewayTenant>>('/api/admin/tenants', { token });
}
export async function createTenant(token: string, input: GatewayTenantUpsertRequest): Promise<GatewayTenant> {
return request<GatewayTenant>('/api/v1/tenants', {
return request<GatewayTenant>('/api/admin/tenants', {
body: input,
method: 'POST',
token,
@@ -248,7 +248,7 @@ export async function createTenant(token: string, input: GatewayTenantUpsertRequ
}
export async function updateTenant(token: string, tenantId: string, input: GatewayTenantUpsertRequest): Promise<GatewayTenant> {
return request<GatewayTenant>(`/api/v1/tenants/${tenantId}`, {
return request<GatewayTenant>(`/api/admin/tenants/${tenantId}`, {
body: input,
method: 'PATCH',
token,
@@ -256,18 +256,18 @@ export async function updateTenant(token: string, tenantId: string, input: Gatew
}
export async function deleteTenant(token: string, tenantId: string): Promise<void> {
await request<void>(`/api/v1/tenants/${tenantId}`, {
await request<void>(`/api/admin/tenants/${tenantId}`, {
method: 'DELETE',
token,
});
}
export async function listUsers(token: string): Promise<ListResponse<GatewayUser>> {
return request<ListResponse<GatewayUser>>('/api/v1/users', { token });
return request<ListResponse<GatewayUser>>('/api/admin/users', { token });
}
export async function createGatewayUser(token: string, input: GatewayUserUpsertRequest): Promise<GatewayUser> {
return request<GatewayUser>('/api/v1/users', {
return request<GatewayUser>('/api/admin/users', {
body: input,
method: 'POST',
token,
@@ -275,7 +275,7 @@ export async function createGatewayUser(token: string, input: GatewayUserUpsertR
}
export async function updateGatewayUser(token: string, userId: string, input: GatewayUserUpsertRequest): Promise<GatewayUser> {
return request<GatewayUser>(`/api/v1/users/${userId}`, {
return request<GatewayUser>(`/api/admin/users/${userId}`, {
body: input,
method: 'PATCH',
token,
@@ -283,18 +283,18 @@ export async function updateGatewayUser(token: string, userId: string, input: Ga
}
export async function deleteGatewayUser(token: string, userId: string): Promise<void> {
await request<void>(`/api/v1/users/${userId}`, {
await request<void>(`/api/admin/users/${userId}`, {
method: 'DELETE',
token,
});
}
export async function listUserGroups(token: string): Promise<ListResponse<UserGroup>> {
return request<ListResponse<UserGroup>>('/api/v1/user-groups', { token });
return request<ListResponse<UserGroup>>('/api/admin/user-groups', { token });
}
export async function createUserGroup(token: string, input: UserGroupUpsertRequest): Promise<UserGroup> {
return request<UserGroup>('/api/v1/user-groups', {
return request<UserGroup>('/api/admin/user-groups', {
body: input,
method: 'POST',
token,
@@ -302,7 +302,7 @@ export async function createUserGroup(token: string, input: UserGroupUpsertReque
}
export async function updateUserGroup(token: string, groupId: string, input: UserGroupUpsertRequest): Promise<UserGroup> {
return request<UserGroup>(`/api/v1/user-groups/${groupId}`, {
return request<UserGroup>(`/api/admin/user-groups/${groupId}`, {
body: input,
method: 'PATCH',
token,
@@ -310,18 +310,22 @@ export async function updateUserGroup(token: string, groupId: string, input: Use
}
export async function deleteUserGroup(token: string, groupId: string): Promise<void> {
await request<void>(`/api/v1/user-groups/${groupId}`, {
await request<void>(`/api/admin/user-groups/${groupId}`, {
method: 'DELETE',
token,
});
}
export async function listAccessRules(token: string): Promise<ListResponse<GatewayAccessRule>> {
return request<ListResponse<GatewayAccessRule>>('/api/v1/access-rules', { token });
return request<ListResponse<GatewayAccessRule>>('/api/admin/access-rules', { token });
}
export async function listApiKeyAccessRules(token: string): Promise<ListResponse<GatewayAccessRule>> {
return request<ListResponse<GatewayAccessRule>>('/api/v1/api-keys/access-rules', { token });
}
export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise<GatewayAccessRule> {
return request<GatewayAccessRule>('/api/v1/access-rules', {
return request<GatewayAccessRule>('/api/admin/access-rules', {
body: input,
method: 'POST',
token,
@@ -329,7 +333,15 @@ export async function createAccessRule(token: string, input: GatewayAccessRuleUp
}
export async function batchAccessRules(token: string, input: GatewayAccessRuleBatchRequest): Promise<ListResponse<GatewayAccessRule>> {
return request<ListResponse<GatewayAccessRule>>('/api/v1/access-rules/batch', {
return request<ListResponse<GatewayAccessRule>>('/api/admin/access-rules/batch', {
body: input,
method: 'POST',
token,
});
}
export async function batchApiKeyAccessRules(token: string, input: GatewayAccessRuleBatchRequest): Promise<ListResponse<GatewayAccessRule>> {
return request<ListResponse<GatewayAccessRule>>('/api/v1/api-keys/access-rules/batch', {
body: input,
method: 'POST',
token,
@@ -341,7 +353,7 @@ export async function updateAccessRule(
ruleId: string,
input: GatewayAccessRuleUpsertRequest,
): Promise<GatewayAccessRule> {
return request<GatewayAccessRule>(`/api/v1/access-rules/${ruleId}`, {
return request<GatewayAccessRule>(`/api/admin/access-rules/${ruleId}`, {
body: input,
method: 'PATCH',
token,
@@ -349,7 +361,7 @@ export async function updateAccessRule(
}
export async function deleteAccessRule(token: string, ruleId: string): Promise<void> {
await request<void>(`/api/v1/access-rules/${ruleId}`, {
await request<void>(`/api/admin/access-rules/${ruleId}`, {
method: 'DELETE',
token,
});
@@ -365,7 +377,7 @@ export async function listPlayableApiKeys(token: string): Promise<ListResponse<P
export async function createApiKey(
token: string,
input: { name: string; scopes?: string[] },
input: { name: string; scopes?: string[]; expiresAt?: string },
): Promise<CreatedGatewayApiKey> {
return request<CreatedGatewayApiKey>('/api/v1/api-keys', {
body: input,
@@ -374,8 +386,15 @@ export async function createApiKey(
});
}
export async function deleteApiKey(token: string, apiKeyId: string): Promise<void> {
await request<void>(`/api/v1/api-keys/${apiKeyId}`, {
method: 'DELETE',
token,
});
}
export async function createPlatform(token: string, input: PlatformCreateInput): Promise<IntegrationPlatform> {
return request<IntegrationPlatform>('/api/v1/platforms', {
return request<IntegrationPlatform>('/api/admin/platforms', {
body: input,
method: 'POST',
token,
@@ -383,7 +402,7 @@ export async function createPlatform(token: string, input: PlatformCreateInput):
}
export async function updatePlatform(token: string, platformId: string, input: PlatformCreateInput): Promise<IntegrationPlatform> {
return request<IntegrationPlatform>(`/api/v1/platforms/${platformId}`, {
return request<IntegrationPlatform>(`/api/admin/platforms/${platformId}`, {
body: input,
method: 'PATCH',
token,
@@ -391,7 +410,7 @@ export async function updatePlatform(token: string, platformId: string, input: P
}
export async function deletePlatform(token: string, platformId: string): Promise<void> {
await request<void>(`/api/v1/platforms/${platformId}`, {
await request<void>(`/api/admin/platforms/${platformId}`, {
method: 'DELETE',
token,
});
@@ -402,7 +421,7 @@ export async function createPlatformModel(
platformId: string,
input: PlatformModelBindingInput,
): Promise<PlatformModel> {
return request<PlatformModel>(`/api/v1/platforms/${platformId}/models`, {
return request<PlatformModel>(`/api/admin/platforms/${platformId}/models`, {
body: input,
method: 'POST',
token,
@@ -414,7 +433,7 @@ export async function replacePlatformModels(
platformId: string,
models: PlatformModelBindingInput[],
): Promise<ListResponse<PlatformModel>> {
return request<ListResponse<PlatformModel>>(`/api/v1/platforms/${platformId}/models`, {
return request<ListResponse<PlatformModel>>(`/api/admin/platforms/${platformId}/models`, {
body: { models },
method: 'PUT',
token,
@@ -422,7 +441,7 @@ export async function replacePlatformModels(
}
export async function deletePlatformModel(token: string, modelId: string): Promise<void> {
await request<void>(`/api/v1/platform-models/${modelId}`, {
await request<void>(`/api/admin/platform-models/${modelId}`, {
method: 'DELETE',
token,
});
@@ -528,7 +547,7 @@ export async function getTask(token: string, taskId: string): Promise<GatewayTas
}
export async function listRateLimitWindows(token: string): Promise<ListResponse<RateLimitWindow>> {
return request<ListResponse<RateLimitWindow>>('/api/v1/runtime/rate-limit-windows', { token });
return request<ListResponse<RateLimitWindow>>('/api/admin/runtime/rate-limit-windows', { token });
}
async function request<T>(
+54
View File
@@ -0,0 +1,54 @@
import * as React from 'react';
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from 'lucide-react';
import { DayPicker, type ChevronProps, type DayPickerProps } from 'react-day-picker';
import { cn } from '../../lib/utils';
export function Calendar(props: DayPickerProps) {
const { className, classNames, showOutsideDays = true, ...rest } = props;
return (
<DayPicker
className={cn('shCalendar', className)}
classNames={{
root: 'shCalendarRoot',
months: 'shCalendarMonths',
month: 'shCalendarMonth',
month_caption: 'shCalendarCaption',
caption_label: 'shCalendarCaptionLabel',
nav: 'shCalendarNav',
button_previous: 'shCalendarNavButton',
button_next: 'shCalendarNavButton',
chevron: 'shCalendarChevron',
month_grid: 'shCalendarGrid',
weekdays: 'shCalendarWeekdays',
weekday: 'shCalendarWeekday',
weeks: 'shCalendarWeeks',
week: 'shCalendarWeek',
day: 'shCalendarDay',
day_button: 'shCalendarDayButton',
outside: 'shCalendarDayOutside',
disabled: 'shCalendarDayDisabled',
selected: 'shCalendarDaySelected',
today: 'shCalendarDayToday',
hidden: 'shCalendarDayHidden',
...classNames,
}}
components={{
Chevron: CalendarChevron,
...props.components,
}}
showOutsideDays={showOutsideDays}
{...rest}
/>
);
}
function CalendarChevron(props: ChevronProps) {
const Icon = props.orientation === 'left'
? ChevronLeft
: props.orientation === 'right'
? ChevronRight
: props.orientation === 'up'
? ChevronUp
: ChevronDown;
return <Icon className={cn('shCalendarChevronIcon', props.className)} size={props.size ?? 16} />;
}
@@ -0,0 +1,87 @@
import { useMemo, useState } from 'react';
import { format } from 'date-fns';
import { CalendarIcon, X } from 'lucide-react';
import { Button } from './button';
import { Calendar } from './calendar';
import { Input } from './input';
import { Label } from './label';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
export function DateTimePicker(props: {
disabled?: boolean;
placeholder?: string;
value: string;
onChange: (value: string) => void;
}) {
const [open, setOpen] = useState(false);
const selectedDate = useMemo(() => parseLocalDateTime(props.value), [props.value]);
const timeValue = selectedDate ? formatTimeValue(selectedDate) : '';
function updateDate(nextDate: Date | undefined) {
if (!nextDate) return;
const current = selectedDate ?? new Date();
const next = new Date(nextDate);
next.setHours(current.getHours(), current.getMinutes(), 0, 0);
props.onChange(formatLocalDateTime(next));
}
function updateTime(value: string) {
const [hours, minutes] = value.split(':').map((item) => Number(item));
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return;
const next = selectedDate ? new Date(selectedDate) : new Date();
next.setHours(hours, minutes, 0, 0);
props.onChange(formatLocalDateTime(next));
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
className="dateTimePickerTrigger"
data-empty={!selectedDate}
disabled={props.disabled}
type="button"
variant="outline"
>
<CalendarIcon size={15} />
{selectedDate ? format(selectedDate, 'yyyy-MM-dd HH:mm') : <span>{props.placeholder ?? '选择日期时间'}</span>}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="dateTimePickerPopover">
<Calendar mode="single" selected={selectedDate} onSelect={updateDate} />
<div className="dateTimePickerFooter">
<Label>
<Input type="time" value={timeValue} onChange={(event) => updateTime(event.target.value)} />
</Label>
<Button type="button" variant="ghost" size="icon" title="清除有效期" disabled={!props.value} onClick={() => props.onChange('')}>
<X size={14} />
</Button>
</div>
</PopoverContent>
</Popover>
);
}
function parseLocalDateTime(value: string) {
if (!value) return undefined;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
}
function formatLocalDateTime(date: Date) {
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function formatTimeValue(date: Date) {
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function pad(value: number) {
return String(value).padStart(2, '0');
}
+3
View File
@@ -1,13 +1,16 @@
export * from './badge';
export * from './button';
export * from './calendar';
export * from './card';
export * from './checkbox';
export * from './confirm-dialog';
export * from './date-time-picker';
export * from './dialog';
export * from './form-item';
export * from './input';
export * from './label';
export * from './message';
export * from './popover';
export * from './select';
export * from './separator';
export * from './table';
+24
View File
@@ -0,0 +1,24 @@
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '../../lib/utils';
export const Popover = PopoverPrimitive.Root;
export const PopoverTrigger = PopoverPrimitive.Trigger;
export const PopoverAnchor = PopoverPrimitive.Anchor;
export const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ align = 'center', className, sideOffset = 6, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
className={cn('shPopoverContent', className)}
ref={ref}
sideOffset={sideOffset}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+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;
}
+97 -1
View File
@@ -285,6 +285,11 @@
gap: 14px;
}
.accessEditorStack {
display: grid;
gap: 12px;
}
.accessPermissionPanel .shCardContent {
display: grid;
gap: 12px;
@@ -388,6 +393,95 @@
min-width: 900px;
}
.apiKeyTable {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
.apiKeyTable .shTableRow {
grid-template-columns: minmax(170px, 1.05fr) minmax(210px, 1.25fr) minmax(150px, 0.9fr) minmax(150px, 0.9fr) minmax(150px, 0.9fr) minmax(96px, 0.55fr) minmax(150px, 0.9fr) minmax(74px, 0.4fr);
min-width: 1160px;
align-items: center;
}
.apiKeyNameCell,
.apiKeySecretCell {
display: grid;
min-width: 0;
gap: 3px;
}
.apiKeyNameCell strong,
.apiKeyNameCell small,
.apiKeySecretCell code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.apiKeyNameCell strong {
color: var(--text-strong);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.apiKeyNameCell small {
color: var(--text-soft);
font-size: var(--font-size-xs);
}
.apiKeySecretCell {
grid-template-columns: minmax(0, 1fr) 30px;
align-items: center;
}
.apiKeySecretCell code {
min-width: 0;
color: var(--text-normal);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
}
.apiKeyPolicyButton {
display: inline-flex;
max-width: 100%;
min-height: 30px;
align-items: center;
gap: 6px;
padding: 0 9px;
border: 1px solid var(--border);
border-radius: var(--control-radius);
background: var(--surface);
color: var(--text-normal);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
.apiKeyPolicyButton span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.apiKeyCreateDialogBody {
grid-template-columns: 1fr;
}
.apiKeyPolicyDialog {
width: min(1120px, 100%);
}
.apiKeyPolicyDialogBody {
grid-template-columns: 1fr;
background: var(--surface-subtle);
}
.apiKeyPolicyDialog .accessTreeBox {
min-height: 360px;
max-height: min(480px, calc(100vh - 360px));
}
.tenantTable .shTableRow {
grid-template-columns: minmax(210px, 1.35fr) minmax(110px, 0.7fr) minmax(150px, 0.9fr) minmax(110px, 0.7fr) minmax(90px, 0.55fr) minmax(86px, 0.5fr);
min-width: 880px;
@@ -1314,7 +1408,9 @@
.runtimePolicyFormBody,
.runtimePolicyRows,
.accessPermissionGrid,
.accessTreeToolbar {
.accessTreeToolbar,
.apiKeyCreateDialogBody,
.apiKeyPolicyDialogBody {
grid-template-columns: 1fr;
}
+154
View File
@@ -346,6 +346,16 @@
box-shadow: 0 -1px 0 rgba(16, 24, 40, 0.02);
}
.shPopoverContent {
z-index: 80;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--popover);
color: var(--popover-foreground);
box-shadow: var(--shadow-dialog);
}
.shInput,
.shTextarea {
width: 100%;
@@ -517,6 +527,150 @@
.shBadgeWarning { background: #fbf4e8; color: #8a6116; }
.shBadgeDestructive { background: #fff3f3; color: #b42318; }
.shCalendar {
padding: 10px;
background: var(--surface);
}
.shCalendarRoot,
.shCalendarMonths,
.shCalendarMonth,
.shCalendarWeeks {
display: grid;
gap: 8px;
}
.shCalendarCaption {
position: relative;
display: flex;
min-height: 32px;
align-items: center;
justify-content: center;
}
.shCalendarCaptionLabel {
color: var(--text-strong);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.shCalendarNav {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: space-between;
pointer-events: none;
}
.shCalendarNavButton {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border: 1px solid var(--border);
border-radius: var(--control-radius);
background: var(--surface);
color: var(--text-normal);
pointer-events: auto;
}
.shCalendarNavButton:hover {
background: var(--accent);
}
.shCalendarChevronIcon {
display: block;
}
.shCalendarGrid {
border-collapse: collapse;
}
.shCalendarWeekdays,
.shCalendarWeek {
display: grid;
grid-template-columns: repeat(7, 32px);
gap: 4px;
}
.shCalendarWeekday {
display: grid;
height: 26px;
place-items: center;
color: var(--text-soft);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
.shCalendarDay {
display: grid;
width: 32px;
height: 32px;
place-items: center;
}
.shCalendarDayButton {
width: 32px;
height: 32px;
border: 0;
border-radius: var(--control-radius);
background: transparent;
color: var(--text-normal);
font-size: var(--font-size-sm);
}
.shCalendarDayButton:hover {
background: var(--accent);
}
.shCalendarDayOutside .shCalendarDayButton {
color: var(--text-faint);
}
.shCalendarDayDisabled .shCalendarDayButton {
cursor: not-allowed;
color: var(--text-faint);
opacity: 0.45;
}
.shCalendarDayToday .shCalendarDayButton {
border: 1px solid var(--ring);
}
.shCalendarDaySelected .shCalendarDayButton {
background: var(--primary);
color: var(--primary-foreground);
}
.shCalendarDayHidden {
visibility: hidden;
}
.dateTimePickerTrigger {
width: 100%;
justify-content: flex-start;
text-align: left;
}
.dateTimePickerTrigger[data-empty="true"] {
color: var(--text-soft);
}
.dateTimePickerPopover {
width: auto;
}
.dateTimePickerFooter {
display: grid;
grid-template-columns: minmax(0, 1fr) 34px;
gap: 10px;
align-items: end;
padding: 10px;
border-top: 1px solid var(--border);
background: var(--surface);
}
.shTable {
overflow: auto;
}
+1
View File
@@ -40,6 +40,7 @@ export interface TaskForm {
export interface ApiKeyForm {
name: string;
expiresAt: string;
}
export interface PlatformForm {