完善 API Key 能力范围可视化和维护
This commit is contained in:
+45
-10
@@ -10,6 +10,7 @@ import type {
|
||||
GatewayAccessRule,
|
||||
GatewayAccessRuleUpsertRequest,
|
||||
GatewayApiKey,
|
||||
GatewayApiKeyScopeUpdateRequest,
|
||||
GatewayAuditLog,
|
||||
GatewayNetworkProxyConfig,
|
||||
GatewayRunnerPolicy,
|
||||
@@ -31,7 +32,7 @@ import type {
|
||||
RuntimePolicySet,
|
||||
UserGroupUpsertRequest,
|
||||
UserGroup,
|
||||
WalletBalanceAdjustmentRequest,
|
||||
WalletRechargeRequest,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import {
|
||||
batchAccessRules,
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
GatewayApiError,
|
||||
getHealth,
|
||||
listFileStorageChannels,
|
||||
getCurrentUser,
|
||||
getFileStorageSettings,
|
||||
getNetworkProxyConfig,
|
||||
getRunnerPolicy,
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
listApiKeys,
|
||||
listBaseModels,
|
||||
listCatalogProviders,
|
||||
listCurrentUserGroups,
|
||||
listModelCatalog,
|
||||
listModelRateLimitStatuses,
|
||||
listModels,
|
||||
@@ -83,11 +86,12 @@ import {
|
||||
loginLocalAccount,
|
||||
pollTaskUntilSettled,
|
||||
registerLocalAccount,
|
||||
rechargeUserWalletBalance,
|
||||
replacePlatformModels,
|
||||
restoreModelRuntimeStatus,
|
||||
setUserWalletBalance,
|
||||
type HealthResponse,
|
||||
updateAccessRule,
|
||||
updateApiKeyScopes,
|
||||
updateFileStorageChannel,
|
||||
updateFileStorageSettings,
|
||||
updateGatewayUser,
|
||||
@@ -142,6 +146,8 @@ import type {
|
||||
|
||||
type DataKey =
|
||||
| 'health'
|
||||
| 'currentUser'
|
||||
| 'currentUserGroups'
|
||||
| 'publicCatalog'
|
||||
| 'playgroundApiKeys'
|
||||
| 'playgroundModels'
|
||||
@@ -184,6 +190,8 @@ export function App() {
|
||||
const [loginForm, setLoginForm] = useState<LoginForm>({ account: '', password: '' });
|
||||
const [registerForm, setRegisterForm] = useState<RegisterForm>({ username: '', email: '', password: '', displayName: '', invitationCode: '' });
|
||||
const [health, setHealth] = useState<HealthResponse | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<ConsoleData['currentUser']>(null);
|
||||
const [currentUserGroups, setCurrentUserGroups] = useState<UserGroup[]>([]);
|
||||
const [platforms, setPlatforms] = useState<IntegrationPlatform[]>([]);
|
||||
const [models, setModels] = useState<PlatformModel[]>([]);
|
||||
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse>({
|
||||
@@ -313,6 +321,8 @@ export function App() {
|
||||
auditLogs,
|
||||
apiKeys,
|
||||
baseModels,
|
||||
currentUser,
|
||||
currentUserGroups,
|
||||
fileStorageChannels,
|
||||
fileStorageSettings,
|
||||
modelCatalog,
|
||||
@@ -334,7 +344,7 @@ export function App() {
|
||||
users,
|
||||
walletAccounts,
|
||||
walletTransactions,
|
||||
}), [accessRules, apiKeys, auditLogs, baseModels, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
|
||||
}), [accessRules, apiKeys, auditLogs, baseModels, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
|
||||
|
||||
async function refresh(nextToken = token) {
|
||||
await ensureRouteData(nextToken, true);
|
||||
@@ -387,6 +397,12 @@ export function App() {
|
||||
setHealth(await getHealth());
|
||||
return;
|
||||
}
|
||||
case 'currentUser':
|
||||
setCurrentUser(await getCurrentUser(nextToken));
|
||||
return;
|
||||
case 'currentUserGroups':
|
||||
setCurrentUserGroups((await listCurrentUserGroups(nextToken)).items);
|
||||
return;
|
||||
case 'publicCatalog': {
|
||||
const [providersResult, baseModelsResult] = await Promise.all([
|
||||
listPublicCatalogProviders(),
|
||||
@@ -541,7 +557,7 @@ export function App() {
|
||||
try {
|
||||
const response = await createApiKey(token, {
|
||||
name: apiKeyForm.name,
|
||||
scopes: ['chat', 'embedding', 'rerank', 'image', 'video'],
|
||||
scopes: ['chat', 'embedding', 'rerank', 'image', 'video', 'music', 'audio'],
|
||||
expiresAt: apiKeyForm.expiresAt ? new Date(apiKeyForm.expiresAt).toISOString() : undefined,
|
||||
});
|
||||
setApiKeySecret(response.secret);
|
||||
@@ -743,11 +759,11 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUserWalletBalance(userId: string, input: WalletBalanceAdjustmentRequest) {
|
||||
async function rechargeUserWallet(userId: string, input: WalletRechargeRequest) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const response = await setUserWalletBalance(token, userId, input);
|
||||
const response = await rechargeUserWalletBalance(token, userId, input);
|
||||
setUsers((current) => current.map((user) => user.id === userId
|
||||
? {
|
||||
...user,
|
||||
@@ -760,10 +776,10 @@ export function App() {
|
||||
setAuditLogs((current) => [response.auditLog, ...current.filter((item) => item.id !== response.auditLog.id)]);
|
||||
invalidateDataKeys('auditLogs');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('用户余额已更新,审计日志已记录。');
|
||||
setCoreMessage('用户余额已充值,审计日志已记录。');
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : '更新用户余额失败');
|
||||
setCoreMessage(err instanceof Error ? err.message : '充值用户余额失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -840,6 +856,22 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAPIKeyScopes(apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const item = await updateApiKeyScopes(token, apiKeyId, input);
|
||||
setApiKeys((current) => current.map((apiKey) => (apiKey.id === item.id ? { ...apiKey, ...item } : apiKey)));
|
||||
invalidateDataKeys('playgroundApiKeys', 'playgroundModels', 'modelCatalog');
|
||||
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('');
|
||||
@@ -988,6 +1020,8 @@ export function App() {
|
||||
loadedDataKeysRef.current = new Set(health ? ['health'] : []);
|
||||
loadingDataKeysRef.current.clear();
|
||||
setState('idle');
|
||||
setCurrentUser(null);
|
||||
setCurrentUserGroups([]);
|
||||
setPlatforms([]);
|
||||
setModels([]);
|
||||
setModelCatalog({ items: [], filters: { capabilities: [], providers: [] }, summary: { modelCount: 0, sourceCount: 0 } });
|
||||
@@ -1143,6 +1177,7 @@ export function App() {
|
||||
onDeleteApiKey={removeAPIKey}
|
||||
onApiKeyFormChange={setApiKeyForm}
|
||||
onSectionChange={navigateWorkspaceSection}
|
||||
onSaveApiKeyScopes={saveAPIKeyScopes}
|
||||
onSubmitApiKey={submitAPIKey}
|
||||
onTaskQueryChange={navigateWorkspaceTaskQuery}
|
||||
onTransactionQueryChange={setWorkspaceTransactionQuery}
|
||||
@@ -1200,7 +1235,7 @@ export function App() {
|
||||
onSaveFileStorageSettings={saveFileStorageSettings}
|
||||
onSaveTenant={saveTenant}
|
||||
onSaveUser={saveUser}
|
||||
onSetUserWalletBalance={saveUserWalletBalance}
|
||||
onRechargeUserWalletBalance={rechargeUserWallet}
|
||||
onSaveUserGroup={saveUserGroup}
|
||||
onClearOperationMessage={() => setCoreMessage('')}
|
||||
onSectionChange={navigateAdminSection}
|
||||
@@ -1382,7 +1417,7 @@ function dataKeysForRoute(
|
||||
if (!isAuthenticated) return [];
|
||||
|
||||
if (activePage === 'workspace') {
|
||||
if (workspaceSection === 'overview') return ['users', 'userGroups', 'apiKeys'];
|
||||
if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys'];
|
||||
if (workspaceSection === 'billing') return ['wallet'];
|
||||
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'playgroundModels'];
|
||||
if (workspaceSection === 'tasks') return ['tasks'];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AuthResponse,
|
||||
AuthUser,
|
||||
BaseModelCatalogItem,
|
||||
BaseModelUpsertRequest,
|
||||
CatalogProvider,
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
GatewayAccessRule,
|
||||
GatewayAccessRuleUpsertRequest,
|
||||
GatewayApiKey,
|
||||
GatewayApiKeyScopeUpdateRequest,
|
||||
GatewayAuditLog,
|
||||
GatewayRunnerPolicy,
|
||||
GatewayRunnerPolicyUpsertRequest,
|
||||
@@ -43,6 +45,7 @@ import type {
|
||||
UserGroupUpsertRequest,
|
||||
WalletAdjustmentResponse,
|
||||
WalletBalanceAdjustmentRequest,
|
||||
WalletRechargeRequest,
|
||||
WalletSummaryResponse,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types';
|
||||
@@ -105,6 +108,10 @@ export async function loginLocalAccount(input: { account: string; password: stri
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCurrentUser(token: string): Promise<AuthUser> {
|
||||
return request<AuthUser>('/api/v1/me', { token });
|
||||
}
|
||||
|
||||
export async function listPlatforms(token: string): Promise<ListResponse<IntegrationPlatform>> {
|
||||
return request<ListResponse<IntegrationPlatform>>('/api/admin/platforms', { token });
|
||||
}
|
||||
@@ -366,6 +373,18 @@ export async function setUserWalletBalance(
|
||||
});
|
||||
}
|
||||
|
||||
export async function rechargeUserWalletBalance(
|
||||
token: string,
|
||||
userId: string,
|
||||
input: WalletRechargeRequest,
|
||||
): Promise<WalletAdjustmentResponse> {
|
||||
return request<WalletAdjustmentResponse>(`/api/admin/users/${userId}/wallet/recharge`, {
|
||||
body: input,
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteGatewayUser(token: string, userId: string): Promise<void> {
|
||||
await request<void>(`/api/admin/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
@@ -381,6 +400,10 @@ export async function listUserGroups(token: string): Promise<ListResponse<UserGr
|
||||
return request<ListResponse<UserGroup>>('/api/admin/user-groups', { token });
|
||||
}
|
||||
|
||||
export async function listCurrentUserGroups(token: string): Promise<ListResponse<UserGroup>> {
|
||||
return request<ListResponse<UserGroup>>('/api/workspace/user-groups', { token });
|
||||
}
|
||||
|
||||
export async function createUserGroup(token: string, input: UserGroupUpsertRequest): Promise<UserGroup> {
|
||||
return request<UserGroup>('/api/admin/user-groups', {
|
||||
body: input,
|
||||
@@ -474,6 +497,14 @@ export async function createApiKey(
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateApiKeyScopes(token: string, apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest): Promise<GatewayApiKey> {
|
||||
return request<GatewayApiKey>(`/api/v1/api-keys/${apiKeyId}/scopes`, {
|
||||
body: input,
|
||||
method: 'PATCH',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteApiKey(token: string, apiKeyId: string): Promise<void> {
|
||||
await request<void>(`/api/v1/api-keys/${apiKeyId}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AuthUser,
|
||||
BaseModelCatalogItem,
|
||||
CatalogProvider,
|
||||
FileStorageChannel,
|
||||
@@ -29,6 +30,8 @@ export interface ConsoleData {
|
||||
auditLogs: GatewayAuditLog[];
|
||||
apiKeys: GatewayApiKey[];
|
||||
baseModels: BaseModelCatalogItem[];
|
||||
currentUser: AuthUser | null;
|
||||
currentUserGroups: UserGroup[];
|
||||
fileStorageChannels: FileStorageChannel[];
|
||||
fileStorageSettings: FileStorageSettings | null;
|
||||
modelCatalog: ModelCatalogResponse;
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
PricingRuleSetUpsertRequest,
|
||||
RuntimePolicySetUpsertRequest,
|
||||
UserGroupUpsertRequest,
|
||||
WalletBalanceAdjustmentRequest,
|
||||
WalletRechargeRequest,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import type { ConsoleData, StatItem } from '../app-state';
|
||||
import { EntityTable } from '../components/EntityTable';
|
||||
@@ -82,7 +82,7 @@ export function AdminPage(props: {
|
||||
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
|
||||
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
|
||||
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
|
||||
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
||||
onClearOperationMessage: () => void;
|
||||
onSectionChange: (value: AdminSection) => void;
|
||||
@@ -90,6 +90,7 @@ export function AdminPage(props: {
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<ScreenMessage
|
||||
duration={props.state === 'error' ? 0 : undefined}
|
||||
message={props.operationMessage}
|
||||
variant={props.state === 'error' ? 'error' : 'success'}
|
||||
onClose={props.onClearOperationMessage}
|
||||
@@ -207,7 +208,7 @@ function identityPanelProps(props: {
|
||||
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
||||
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
|
||||
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
|
||||
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
||||
}) {
|
||||
return {
|
||||
@@ -219,7 +220,7 @@ function identityPanelProps(props: {
|
||||
onDeleteUserGroup: props.onDeleteUserGroup,
|
||||
onSaveTenant: props.onSaveTenant,
|
||||
onSaveUser: props.onSaveUser,
|
||||
onSetUserWalletBalance: props.onSetUserWalletBalance,
|
||||
onRechargeUserWalletBalance: props.onRechargeUserWalletBalance,
|
||||
onSaveUserGroup: props.onSaveUserGroup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Popover as AntPopover } from 'antd';
|
||||
import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, ReceiptText, RotateCcw, Search, ShieldCheck, SlidersHorizontal, Trash2, UserRound } from 'lucide-react';
|
||||
import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, GatewayTaskParamPreprocessingLog, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayApiKeyScopeUpdateRequest, GatewayTask, GatewayTaskParamPreprocessingLog, GatewayWalletAccount, GatewayWalletTransaction, 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, ConfirmDialog, DateTimePicker, DateTimeRangePicker, FormDialog, Input, Label, Select, Table, TableCell, TableFooter, TableHead, TablePageActions, TableRow, TableToolbar, TableViewportLayout, Tabs } from '../components/ui';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, ConfirmDialog, DateTimePicker, DateTimeRangePicker, FormDialog, Input, Label, Select, Table, TableCell, TableFooter, TableHead, TablePageActions, TableRow, TableToolbar, TableViewportLayout, Tabs } from '../components/ui';
|
||||
import { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor';
|
||||
import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery, WorkspaceTransactionQuery } from '../types';
|
||||
import { listTaskParamPreprocessing } from '../api';
|
||||
@@ -19,6 +19,19 @@ const tabs = [
|
||||
|
||||
const taskPageSizeOptions = [10, 20, 50];
|
||||
|
||||
const apiKeyScopeOptions = [
|
||||
{ value: 'chat', label: '对话', description: 'Chat Completions / Responses' },
|
||||
{ value: 'embedding', label: '向量', description: 'Embeddings' },
|
||||
{ value: 'rerank', label: '重排', description: 'Reranks' },
|
||||
{ value: 'image', label: '图像', description: 'Images' },
|
||||
{ value: 'video', label: '视频', description: 'Videos' },
|
||||
{ value: 'music', label: '音乐生成', description: 'Song / Music' },
|
||||
{ value: 'audio', label: '语音合成', description: 'Speech / TTS' },
|
||||
{ value: 'all', label: '全部能力', description: '不按接口能力限制' },
|
||||
] as const;
|
||||
|
||||
const knownApiKeyScopes = new Set(apiKeyScopeOptions.map((item) => item.value));
|
||||
|
||||
export function WorkspacePage(props: {
|
||||
apiKeyForm: ApiKeyForm;
|
||||
apiKeySecret: string;
|
||||
@@ -37,6 +50,7 @@ export function WorkspacePage(props: {
|
||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||
onSectionChange: (value: WorkspaceSection) => void;
|
||||
onSaveApiKeyScopes: (apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest) => Promise<void>;
|
||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
onTaskQueryChange: (value: WorkspaceTaskQuery) => void;
|
||||
onTransactionQueryChange: (value: WorkspaceTransactionQuery) => void;
|
||||
@@ -59,7 +73,8 @@ export function WorkspacePage(props: {
|
||||
}
|
||||
|
||||
function WorkspaceOverview(props: { data: ConsoleData }) {
|
||||
const owner = props.data.users[0];
|
||||
const owner = props.data.currentUser;
|
||||
const groupRows = currentUserGroupRows(owner, props.data.currentUserGroups);
|
||||
return (
|
||||
<section className="contentGrid two">
|
||||
<Card>
|
||||
@@ -81,7 +96,7 @@ function WorkspaceOverview(props: { data: ConsoleData }) {
|
||||
<EntityTable
|
||||
columns={['用户组', '优先级', '状态', '来源']}
|
||||
empty="暂无用户组"
|
||||
rows={props.data.userGroups.map((item) => [item.groupKey, item.priority, item.status, item.source])}
|
||||
rows={groupRows}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -89,6 +104,23 @@ function WorkspaceOverview(props: { data: ConsoleData }) {
|
||||
);
|
||||
}
|
||||
|
||||
function currentUserGroupRows(owner: ConsoleData['currentUser'], groups: ConsoleData['userGroups']) {
|
||||
if (!owner) return [];
|
||||
const ownerGroupKeys = Array.from(new Set([
|
||||
owner.userGroupKey,
|
||||
...(owner.userGroupKeys ?? []),
|
||||
].filter((value): value is string => Boolean(value?.trim()))));
|
||||
const ownerGroupIds = new Set([
|
||||
owner.userGroupId,
|
||||
...(owner.userGroupKeys ?? []),
|
||||
].filter((value): value is string => Boolean(value?.trim())));
|
||||
const matchedGroups = groups.filter((group) => ownerGroupIds.has(group.id) || ownerGroupKeys.includes(group.groupKey));
|
||||
if (matchedGroups.length) {
|
||||
return matchedGroups.map((item) => [item.groupKey, item.priority, item.status, item.source]);
|
||||
}
|
||||
return ownerGroupKeys.map((groupKey) => [groupKey, '-', '-', owner.source ?? 'gateway']);
|
||||
}
|
||||
|
||||
function BillingPanel(props: { walletAccounts: GatewayWalletAccount[] }) {
|
||||
const primaryWallet = primaryWalletAccount(props.walletAccounts);
|
||||
const availableBalance = primaryWallet ? primaryWallet.balance - primaryWallet.frozenBalance : 0;
|
||||
@@ -364,17 +396,25 @@ function ApiKeyPanel(props: {
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||
onSaveApiKeyScopes: (apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest) => Promise<void>;
|
||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
||||
}) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [policyApiKeyId, setPolicyApiKeyId] = useState('');
|
||||
const [scopeApiKeyId, setScopeApiKeyId] = useState('');
|
||||
const [scopeDraft, setScopeDraft] = useState<string[]>([]);
|
||||
const [scopeError, setScopeError] = 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 selectedScopeKey = useMemo(
|
||||
() => props.data.apiKeys.find((item) => item.id === scopeApiKeyId),
|
||||
[scopeApiKeyId, props.data.apiKeys],
|
||||
);
|
||||
const permissionPlatforms = useMemo(() => platformsForPermissionTree(props.apiKeyPolicyModels), [props.apiKeyPolicyModels]);
|
||||
|
||||
async function copyApiKey(item: GatewayApiKey) {
|
||||
@@ -399,6 +439,34 @@ function ApiKeyPanel(props: {
|
||||
setPendingDelete(null);
|
||||
}
|
||||
|
||||
function openScopeDialog(item: GatewayApiKey) {
|
||||
setScopeApiKeyId(item.id);
|
||||
setScopeDraft(normalizeApiKeyScopes(item.scopes));
|
||||
setScopeError('');
|
||||
}
|
||||
|
||||
function closeScopeDialog() {
|
||||
setScopeApiKeyId('');
|
||||
setScopeDraft([]);
|
||||
setScopeError('');
|
||||
}
|
||||
|
||||
async function submitScopes(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!selectedScopeKey) return;
|
||||
const scopes = normalizeApiKeyScopes(scopeDraft);
|
||||
if (!scopes.length) {
|
||||
setScopeError('至少保留一个能力范围。');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await props.onSaveApiKeyScopes(selectedScopeKey.id, { scopes });
|
||||
closeScopeDialog();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
@@ -418,6 +486,7 @@ function ApiKeyPanel(props: {
|
||||
<TableRow className="shTableHeader">
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>能力范围</TableHead>
|
||||
<TableHead>权限策略</TableHead>
|
||||
<TableHead>最近使用</TableHead>
|
||||
<TableHead>有效期</TableHead>
|
||||
@@ -451,6 +520,14 @@ function ApiKeyPanel(props: {
|
||||
</Button>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="apiKeyScopeCell">
|
||||
<ApiKeyScopeBadges scopes={item.scopes} />
|
||||
<Button type="button" variant="ghost" size="icon" title="维护能力范围" onClick={() => openScopeDialog(item)}>
|
||||
<SlidersHorizontal size={14} />
|
||||
</Button>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button type="button" className="apiKeyPolicyButton" onClick={() => setPolicyApiKeyId(item.id)}>
|
||||
<ShieldCheck size={14} />
|
||||
@@ -509,6 +586,30 @@ function ApiKeyPanel(props: {
|
||||
</Label>
|
||||
</FormDialog>
|
||||
|
||||
<FormDialog
|
||||
ariaLabel="维护 API Key 能力范围"
|
||||
bodyClassName="apiKeyScopeDialogBody"
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={closeScopeDialog}>取消</Button>
|
||||
<Button type="submit" size="sm" disabled={props.state === 'loading' || normalizeApiKeyScopes(scopeDraft).length === 0}>
|
||||
<SlidersHorizontal size={15} />
|
||||
保存范围
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
open={Boolean(selectedScopeKey)}
|
||||
title={selectedScopeKey ? `能力范围:${selectedScopeKey.name}` : '能力范围'}
|
||||
onClose={closeScopeDialog}
|
||||
onSubmit={(event) => void submitScopes(event)}
|
||||
>
|
||||
{scopeError && <p className="formMessage error">{scopeError}</p>}
|
||||
<APIKeyScopeEditor value={scopeDraft} onChange={(scopes) => {
|
||||
setScopeDraft(scopes);
|
||||
if (scopeError) setScopeError('');
|
||||
}} />
|
||||
</FormDialog>
|
||||
|
||||
<FormDialog
|
||||
ariaLabel="维护 API Key 权限策略"
|
||||
bodyClassName="apiKeyPolicyDialogBody"
|
||||
@@ -544,6 +645,64 @@ function ApiKeyPanel(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function APIKeyScopeEditor(props: {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const scopes = normalizeApiKeyScopes(props.value);
|
||||
const customScopes = scopes.filter((scope) => !knownApiKeyScopes.has(scope as (typeof apiKeyScopeOptions)[number]['value']));
|
||||
const customValue = customScopes.join(', ');
|
||||
|
||||
function setKnownScope(scope: string, checked: boolean) {
|
||||
const next = new Set(scopes);
|
||||
if (checked) {
|
||||
if (scope === 'all') {
|
||||
for (const option of apiKeyScopeOptions) {
|
||||
next.delete(option.value);
|
||||
}
|
||||
} else {
|
||||
next.delete('all');
|
||||
}
|
||||
next.add(scope);
|
||||
} else {
|
||||
next.delete(scope);
|
||||
}
|
||||
props.onChange(Array.from(next));
|
||||
}
|
||||
|
||||
function setCustomScopes(value: string) {
|
||||
const known = scopes.filter((scope) => knownApiKeyScopes.has(scope as (typeof apiKeyScopeOptions)[number]['value']));
|
||||
props.onChange(normalizeApiKeyScopes([...known, ...value.split(',')]));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="apiKeyScopeEditor">
|
||||
<div className="apiKeyScopeOptionGrid">
|
||||
{apiKeyScopeOptions.map((option) => (
|
||||
<label key={option.value} className="apiKeyScopeOption">
|
||||
<Checkbox
|
||||
checked={scopes.includes(option.value)}
|
||||
onCheckedChange={(checked) => setKnownScope(option.value, checked === true)}
|
||||
/>
|
||||
<span>
|
||||
<strong>{option.label}</strong>
|
||||
<small>{option.description}</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Label>
|
||||
自定义 scope
|
||||
<Input
|
||||
value={customValue}
|
||||
placeholder="例如:text_to_speech, audio_generate"
|
||||
onChange={(event) => setCustomScopes(event.target.value)}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskPanel(props: {
|
||||
data: ConsoleData;
|
||||
query: WorkspaceTaskQuery;
|
||||
@@ -1429,6 +1588,40 @@ function apiKeySecretFor(item: GatewayApiKey, secretsById: Record<string, string
|
||||
return secretsById[item.id] || item.secret || '';
|
||||
}
|
||||
|
||||
function ApiKeyScopeBadges(props: { scopes?: string[] }) {
|
||||
const scopes = normalizeApiKeyScopes(props.scopes);
|
||||
if (!scopes.length) {
|
||||
return <Badge variant="warning">未配置</Badge>;
|
||||
}
|
||||
const visibleScopes = scopes.includes('all') || scopes.includes('*') ? ['all'] : scopes.slice(0, 3);
|
||||
const hiddenCount = scopes.length - visibleScopes.length;
|
||||
return (
|
||||
<span className="apiKeyScopeBadges">
|
||||
{visibleScopes.map((scope) => (
|
||||
<Badge key={scope} variant={scope === 'all' ? 'success' : 'outline'}>{apiKeyScopeLabel(scope)}</Badge>
|
||||
))}
|
||||
{hiddenCount > 0 && <Badge variant="secondary">+{hiddenCount}</Badge>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeApiKeyScopes(scopes?: string[]) {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const value of scopes ?? []) {
|
||||
let scope = value.trim().toLowerCase();
|
||||
if (scope === '*') scope = 'all';
|
||||
if (!scope || seen.has(scope)) continue;
|
||||
seen.add(scope);
|
||||
out.push(scope);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function apiKeyScopeLabel(scope: string) {
|
||||
return apiKeyScopeOptions.find((item) => item.value === scope)?.label ?? scope;
|
||||
}
|
||||
|
||||
function maskApiKey(secret: string) {
|
||||
if (secret.length <= 18) return secret;
|
||||
return `${secret.slice(0, 12)}...${secret.slice(-4)}`;
|
||||
|
||||
@@ -62,6 +62,7 @@ export function AuditLogsPanel(props: { auditLogs: GatewayAuditLog[]; message?:
|
||||
|
||||
function actionLabel(action: string) {
|
||||
if (action === 'wallet.balance.set') return '余额调整';
|
||||
if (action === 'wallet.balance.recharge') return '余额充值';
|
||||
return action;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
GatewayWalletAccount,
|
||||
UserGroup,
|
||||
UserGroupUpsertRequest,
|
||||
WalletBalanceAdjustmentRequest,
|
||||
WalletRechargeRequest,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import {
|
||||
Badge,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FormDialog,
|
||||
Input,
|
||||
Label,
|
||||
ScreenMessage,
|
||||
Select,
|
||||
Table,
|
||||
TableCell,
|
||||
@@ -68,7 +69,7 @@ type UserForm = {
|
||||
|
||||
type WalletForm = {
|
||||
currency: string;
|
||||
balance: string;
|
||||
amount: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
@@ -149,11 +150,12 @@ export function TenantsPanel(props: IdentityPanelProps) {
|
||||
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<ScreenMessage message={identityLocalErrorMessage(localError, props)} variant="error" duration={0} onClose={() => setLocalError('')} />
|
||||
<IdentityHeader
|
||||
count={props.data.tenants.length}
|
||||
description="租户可独立维护,也可承载 server-main 同步过来的租户标识。"
|
||||
icon={<Building2 size={17} />}
|
||||
message={localError || props.operationMessage}
|
||||
message={identityHeaderMessage(props)}
|
||||
title="租户管理"
|
||||
actionLabel="新增租户"
|
||||
onCreate={openCreateDialog}
|
||||
@@ -256,8 +258,8 @@ export function UsersPanel(props: IdentityPanelProps) {
|
||||
setWalletUser(user);
|
||||
setWalletForm({
|
||||
currency: wallet?.currency ?? 'resource',
|
||||
balance: String(wallet?.balance ?? 0),
|
||||
reason: '',
|
||||
amount: '',
|
||||
reason: defaultRechargeReason,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -290,9 +292,9 @@ export function UsersPanel(props: IdentityPanelProps) {
|
||||
event.preventDefault();
|
||||
setLocalError('');
|
||||
if (!walletUser) return;
|
||||
const balance = Number(walletForm.balance);
|
||||
if (!Number.isFinite(balance) || balance < 0) {
|
||||
setLocalError('余额必须是非负数字');
|
||||
const amount = Number(walletForm.amount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setLocalError('充值金额必须大于 0');
|
||||
return;
|
||||
}
|
||||
if (!walletForm.reason.trim()) {
|
||||
@@ -300,15 +302,15 @@ export function UsersPanel(props: IdentityPanelProps) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await props.onSetUserWalletBalance(walletUser.id, {
|
||||
await props.onRechargeUserWalletBalance(walletUser.id, {
|
||||
currency: walletForm.currency,
|
||||
balance,
|
||||
amount,
|
||||
reason: walletForm.reason.trim(),
|
||||
idempotencyKey: newIdempotencyKey(),
|
||||
});
|
||||
closeWalletDialog();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '更新余额失败');
|
||||
setLocalError(err instanceof Error ? err.message : '充值用户余额失败');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,11 +321,12 @@ export function UsersPanel(props: IdentityPanelProps) {
|
||||
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<ScreenMessage message={identityLocalErrorMessage(localError, props)} variant="error" duration={0} onClose={() => setLocalError('')} />
|
||||
<IdentityHeader
|
||||
count={props.data.users.length}
|
||||
description="支持本地用户闭环,也保留 server-main 用户同步字段。"
|
||||
icon={<UserRound size={17} />}
|
||||
message={localError || props.operationMessage}
|
||||
message={identityHeaderMessage(props)}
|
||||
title="用户管理"
|
||||
actionLabel="新增用户"
|
||||
onCreate={openCreateDialog}
|
||||
@@ -400,18 +403,19 @@ export function UsersPanel(props: IdentityPanelProps) {
|
||||
onConfirm={() => pendingDeleteUser ? deleteUser(pendingDeleteUser) : undefined}
|
||||
/>
|
||||
<FormDialog
|
||||
ariaLabel="修改用户余额"
|
||||
ariaLabel="充值用户余额"
|
||||
className="identityDialog walletDialog"
|
||||
eyebrow="Wallet Adjustment"
|
||||
footer={<WalletDialogFooter loading={props.state === 'loading'} onCancel={closeWalletDialog} />}
|
||||
open={Boolean(walletUser)}
|
||||
title={`修改余额${walletUser ? ` · ${walletUser.displayName || walletUser.username}` : ''}`}
|
||||
title={`充值余额${walletUser ? ` · ${walletUser.displayName || walletUser.username}` : ''}`}
|
||||
onClose={closeWalletDialog}
|
||||
onSubmit={submitWallet}
|
||||
>
|
||||
<Label>币种<Input size="sm" value={walletForm.currency} onChange={(event) => setWalletForm({ ...walletForm, currency: event.target.value })} placeholder="resource" /></Label>
|
||||
<Label>目标余额<Input size="sm" value={walletForm.balance} inputMode="decimal" onChange={(event) => setWalletForm({ ...walletForm, balance: event.target.value })} /></Label>
|
||||
<Label className="spanTwo">调整原因<Textarea size="sm" rows={3} value={walletForm.reason} onChange={(event) => setWalletForm({ ...walletForm, reason: event.target.value })} placeholder="例如:线下充值确认 / 客服补偿 / 账务修正" /></Label>
|
||||
<Label>充值金额<Input size="sm" value={walletForm.amount} inputMode="decimal" onChange={(event) => setWalletForm({ ...walletForm, amount: event.target.value })} placeholder="100" /></Label>
|
||||
<Label className="spanTwo">当前余额<Input size="sm" value={walletUser ? walletSummary(walletUser) : '0 resource'} disabled /></Label>
|
||||
<Label className="spanTwo">充值原因<Textarea size="sm" rows={3} value={walletForm.reason} onChange={(event) => setWalletForm({ ...walletForm, reason: event.target.value })} placeholder="例如:线下充值确认 / 客服补偿 / 账务修正" /></Label>
|
||||
</FormDialog>
|
||||
</div>
|
||||
);
|
||||
@@ -466,11 +470,12 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
|
||||
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<ScreenMessage message={identityLocalErrorMessage(localError, props)} variant="error" duration={0} onClose={() => setLocalError('')} />
|
||||
<IdentityHeader
|
||||
count={props.data.userGroups.length}
|
||||
description="用户组承载充值折扣、计费折扣、限流和额度策略。"
|
||||
icon={<UsersRound size={17} />}
|
||||
message={localError || props.operationMessage}
|
||||
message={identityHeaderMessage(props)}
|
||||
title="用户组管理"
|
||||
actionLabel="新增用户组"
|
||||
onCreate={openCreateDialog}
|
||||
@@ -546,10 +551,22 @@ type IdentityPanelProps = {
|
||||
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
||||
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
|
||||
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
|
||||
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const defaultRechargeReason = '手动充值';
|
||||
|
||||
function identityHeaderMessage(props: Pick<IdentityPanelProps, 'operationMessage' | 'state'>) {
|
||||
return props.state === 'error' ? '' : props.operationMessage;
|
||||
}
|
||||
|
||||
function identityLocalErrorMessage(localError: string, props: Pick<IdentityPanelProps, 'operationMessage' | 'state'>) {
|
||||
if (!localError) return '';
|
||||
if (props.state === 'error' && localError === props.operationMessage) return '';
|
||||
return localError;
|
||||
}
|
||||
|
||||
function IdentityHeader(props: {
|
||||
actionLabel: string;
|
||||
count: number;
|
||||
@@ -621,7 +638,7 @@ function WalletDialogFooter(props: { loading: boolean; onCancel: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={props.onCancel}><RotateCcw size={15} />取消</Button>
|
||||
<Button type="submit" disabled={props.loading}><CircleDollarSign size={15} />保存余额</Button>
|
||||
<Button type="submit" disabled={props.loading}><CircleDollarSign size={15} />确认充值</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -715,8 +732,8 @@ function defaultUserForm(tenant?: GatewayTenant): UserForm {
|
||||
function defaultWalletForm(): WalletForm {
|
||||
return {
|
||||
currency: 'resource',
|
||||
balance: '0',
|
||||
reason: '',
|
||||
amount: '',
|
||||
reason: defaultRechargeReason,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -577,8 +577,8 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
grid-template-columns: minmax(170px, 1.05fr) minmax(210px, 1.2fr) minmax(220px, 1.2fr) minmax(150px, 0.85fr) minmax(150px, 0.85fr) minmax(150px, 0.85fr) minmax(96px, 0.5fr) minmax(150px, 0.85fr) minmax(74px, 0.38fr);
|
||||
min-width: 1360px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -620,6 +620,27 @@
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.apiKeyScopeCell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 30px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.apiKeyScopeBadges {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.apiKeyScopeCell .shBadge,
|
||||
.apiKeyScopeBadges .shBadge {
|
||||
min-height: 20px;
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.apiKeyPolicyButton {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
@@ -645,6 +666,52 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.apiKeyScopeDialogBody {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.apiKeyScopeEditor {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.apiKeyScopeOptionGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.apiKeyScopeOption {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.apiKeyScopeOption span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.apiKeyScopeOption strong {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.apiKeyScopeOption small {
|
||||
overflow: hidden;
|
||||
color: var(--text-soft);
|
||||
font-size: var(--font-size-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.apiKeyPolicyDialog {
|
||||
width: min(1120px, 100%);
|
||||
}
|
||||
@@ -714,6 +781,10 @@
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.formMessage.error {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.providerCatalogGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -2216,8 +2287,13 @@
|
||||
.runnerActionGrid,
|
||||
.accessPermissionGrid,
|
||||
.accessTreeToolbar,
|
||||
.apiKeyCreateDialogBody,
|
||||
.apiKeyPolicyDialogBody {
|
||||
.apiKeyCreateDialogBody,
|
||||
.apiKeyScopeDialogBody,
|
||||
.apiKeyPolicyDialogBody {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.apiKeyScopeOptionGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user