diff --git a/apps/web/src/components/config/ApiKeyAccessPolicyModal.tsx b/apps/web/src/components/config/ApiKeyAccessPolicyModal.tsx new file mode 100644 index 000000000..255db4348 --- /dev/null +++ b/apps/web/src/components/config/ApiKeyAccessPolicyModal.tsx @@ -0,0 +1,226 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/Button'; +import { Modal } from '@/components/ui/Modal'; +import { + nativeKeyPolicyApi, + type NativeKeyGrant, + type NativeKeyPolicy +} from '@/services/api/nativeKeyPolicy'; + +type Props = { + open: boolean; + keyHash: string; + keyLabel: string; + disabled?: boolean; + onClose: () => void; +}; + +const emptyGrant = (): NativeKeyGrant => ({ provider: '', model: '' }); + +const numberValue = (value: string): number => { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +}; + +export function ApiKeyAccessPolicyModal({ + open, + keyHash, + keyLabel, + disabled, + onClose +}: Props) { + const { t } = useTranslation(); + const [enabled, setEnabled] = useState(true); + const [grants, setGrants] = useState([emptyGrant()]); + const [rpm, setRpm] = useState('0'); + const [dailyCalls, setDailyCalls] = useState('0'); + const [weeklyCalls, setWeeklyCalls] = useState('0'); + const [dailyTokens, setDailyTokens] = useState('0'); + const [weeklyTokens, setWeeklyTokens] = useState('0'); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (!open || !keyHash) return; + let cancelled = false; + setLoading(true); + setError(''); + void nativeKeyPolicyApi + .list() + .then((policies) => { + if (cancelled) return; + const current = policies.find( + (policy) => policy.key_hash.toLowerCase() === keyHash.toLowerCase() + ); + setEnabled(current?.enabled ?? true); + setGrants(current?.grants?.length ? current.grants : [emptyGrant()]); + setRpm(String(current?.rpm ?? 0)); + setDailyCalls(String(current?.daily_calls ?? 0)); + setWeeklyCalls(String(current?.weekly_calls ?? 0)); + setDailyTokens(String(current?.daily_tokens ?? 0)); + setWeeklyTokens(String(current?.weekly_tokens ?? 0)); + }) + .catch((cause) => { + if (!cancelled) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [keyHash, open]); + + const updateGrant = (index: number, field: keyof NativeKeyGrant, value: string) => { + setGrants((previous) => + previous.map((grant, grantIndex) => + grantIndex === index ? { ...grant, [field]: value } : grant + ) + ); + }; + + const save = async () => { + const normalizedGrants = grants + .map((grant) => ({ + provider: grant.provider.trim().toLowerCase(), + model: grant.model.trim() + })) + .filter((grant) => grant.provider && grant.model); + if (normalizedGrants.length === 0) { + setError(t('config_management.visual.api_keys.policy_grant_required')); + return; + } + const policy: NativeKeyPolicy = { + key_hash: keyHash, + enabled, + grants: normalizedGrants, + rpm: numberValue(rpm), + daily_calls: numberValue(dailyCalls), + weekly_calls: numberValue(weeklyCalls), + daily_tokens: numberValue(dailyTokens), + weekly_tokens: numberValue(weeklyTokens) + }; + setSaving(true); + setError(''); + try { + await nativeKeyPolicyApi.save(policy); + onClose(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setSaving(false); + } + }; + + return ( + + + + + } + > + {loading ?
{t('common.loading')}
: null} +
+ +
+ {t('config_management.visual.api_keys.policy_identity_hint')} +
+
+ +
+ + {grants.map((grant, index) => ( +
+ updateGrant(index, 'provider', event.target.value)} + disabled={disabled || loading || saving} + /> + updateGrant(index, 'model', event.target.value)} + disabled={disabled || loading || saving} + /> + +
+ ))} + +
+ +
+ +
+ {[ + ['policy_rpm', rpm, setRpm], + ['policy_daily_calls', dailyCalls, setDailyCalls], + ['policy_weekly_calls', weeklyCalls, setWeeklyCalls], + ['policy_daily_tokens', dailyTokens, setDailyTokens], + ['policy_weekly_tokens', weeklyTokens, setWeeklyTokens] + ].map(([label, value, setter]) => ( + + ))} +
+
{t('config_management.visual.api_keys.policy_unlimited_hint')}
+
+ {error ?
{error}
: null} +
+ ); +} diff --git a/apps/web/src/components/config/ApiKeysCardEditor.tsx b/apps/web/src/components/config/ApiKeysCardEditor.tsx index 43dad11a6..358230331 100644 --- a/apps/web/src/components/config/ApiKeysCardEditor.tsx +++ b/apps/web/src/components/config/ApiKeysCardEditor.tsx @@ -14,6 +14,8 @@ import { sha256Hex } from '@/utils/apiKeyHash'; import { isValidApiKeyCharset } from '@/utils/validation'; import { makeClientId } from '@/types/visualConfig'; import styles from './VisualConfigEditor.module.scss'; +import { ApiKeyAccessPolicyModal } from './ApiKeyAccessPolicyModal'; +import { nativeKeyPolicyApi } from '@/services/api/nativeKeyPolicy'; type OrphanAliasConflict = { apiKeyHash: string; @@ -71,6 +73,9 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({ const [aliasInputValue, setAliasInputValue] = useState(''); const [aliasFormError, setAliasFormError] = useState(''); const [aliasSaving, setAliasSaving] = useState(false); + const [policyKeyHash, setPolicyKeyHash] = useState(''); + const [policyKeyLabel, setPolicyKeyLabel] = useState(''); + const [nativePolicyAvailable, setNativePolicyAvailable] = useState(false); const aliasByHash = useMemo(() => { const map = new Map(); @@ -126,6 +131,21 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({ }; }, [managementKey, resolveAliasServiceBase]); + useEffect(() => { + let cancelled = false; + void nativeKeyPolicyApi + .isNativeAccessAvailable() + .then((available) => { + if (!cancelled) setNativePolicyAvailable(available); + }) + .catch(() => { + if (!cancelled) setNativePolicyAvailable(false); + }); + return () => { + cancelled = true; + }; + }, []); + function generateSecureApiKey(): string { const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const array = new Uint8Array(64); @@ -534,6 +554,19 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
{maskApiKey(String(key || ''))}
+ {nativePolicyAvailable ? ( + + ) : null}
); }); diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index b9516fc79..4c25d21fc 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -2941,7 +2941,24 @@ "alias_error_too_long": "Alias must be 120 characters or less", "alias_error_duplicate": "Alias already exists. Use a unique alias.", "error_empty": "Please enter an API key", - "error_invalid": "API key contains invalid characters" + "error_invalid": "API key contains invalid characters", + "policy_action": "Access & quotas", + "policy_title": "Access and quotas for {{name}}", + "policy_save": "Save policy", + "policy_enabled": "Allow this key to make requests", + "policy_identity_hint": "This policy binds directly to the native CPA key. The plugin does not issue or store another key.", + "policy_grants": "Provider and model access", + "policy_provider": "Provider (supports *)", + "policy_model": "Model (supports * and ?)", + "policy_add_grant": "Add access rule", + "policy_grant_required": "At least one complete provider and model rule is required", + "policy_quotas": "Quotas", + "policy_rpm": "Requests per minute", + "policy_daily_calls": "Daily calls", + "policy_weekly_calls": "Weekly calls", + "policy_daily_tokens": "Daily tokens", + "policy_weekly_tokens": "Weekly tokens", + "policy_unlimited_hint": "Zero means unlimited. Policies can also be updated transactionally through the Management API." }, "payload_rules": { "rule": "Rule", diff --git a/apps/web/src/i18n/locales/ru.json b/apps/web/src/i18n/locales/ru.json index ebe4bb840..5464a9078 100644 --- a/apps/web/src/i18n/locales/ru.json +++ b/apps/web/src/i18n/locales/ru.json @@ -2941,7 +2941,24 @@ "alias_error_too_long": "Псевдоним должен быть не длиннее 120 символов", "alias_error_duplicate": "Псевдоним уже существует. Используйте уникальный псевдоним.", "error_empty": "Введите API-ключ", - "error_invalid": "API-ключ содержит недопустимые символы" + "error_invalid": "API-ключ содержит недопустимые символы", + "policy_action": "Доступ и квоты", + "policy_title": "Доступ и квоты для {{name}}", + "policy_save": "Сохранить политику", + "policy_enabled": "Разрешить запросы для этого ключа", + "policy_identity_hint": "Политика привязана к исходному ключу CPA. Плагин не выпускает и не хранит второй ключ.", + "policy_grants": "Доступ к провайдерам и моделям", + "policy_provider": "Провайдер (поддерживает *)", + "policy_model": "Модель (поддерживает * и ?)", + "policy_add_grant": "Добавить правило", + "policy_grant_required": "Требуется хотя бы одно полное правило провайдера и модели", + "policy_quotas": "Квоты", + "policy_rpm": "Запросов в минуту", + "policy_daily_calls": "Вызовов в день", + "policy_weekly_calls": "Вызовов в неделю", + "policy_daily_tokens": "Токенов в день", + "policy_weekly_tokens": "Токенов в неделю", + "policy_unlimited_hint": "Ноль означает отсутствие лимита. Политики также можно обновлять транзакционно через Management API." }, "payload_rules": { "rule": "Правило", diff --git a/apps/web/src/i18n/locales/zh-CN.json b/apps/web/src/i18n/locales/zh-CN.json index 6e8ff8ee7..0c6d2f4b4 100644 --- a/apps/web/src/i18n/locales/zh-CN.json +++ b/apps/web/src/i18n/locales/zh-CN.json @@ -2941,7 +2941,24 @@ "alias_error_too_long": "别名不能超过 120 个字符", "alias_error_duplicate": "别名已存在,请换一个唯一别名", "error_empty": "请输入 API 密钥", - "error_invalid": "API 密钥包含无效字符" + "error_invalid": "API 密钥包含无效字符", + "policy_action": "权限与额度", + "policy_title": "{{name}} 的权限与额度", + "policy_save": "保存策略", + "policy_enabled": "允许此 Key 发起请求", + "policy_identity_hint": "此策略直接绑定 CPA 原生 Key;插件不会签发或保存另一把 Key。", + "policy_grants": "提供商与模型权限", + "policy_provider": "提供商(支持 *)", + "policy_model": "模型(支持 * 和 ?)", + "policy_add_grant": "添加权限", + "policy_grant_required": "至少需要一条完整的提供商与模型权限", + "policy_quotas": "额度限制", + "policy_rpm": "每分钟请求数", + "policy_daily_calls": "每日调用次数", + "policy_weekly_calls": "每周调用次数", + "policy_daily_tokens": "每日 Token", + "policy_weekly_tokens": "每周 Token", + "policy_unlimited_hint": "数值为 0 表示不限制。策略也可通过 Management API 事务式批量更新。" }, "payload_rules": { "rule": "规则", diff --git a/apps/web/src/i18n/locales/zh-TW.json b/apps/web/src/i18n/locales/zh-TW.json index 931f000cf..fb32a2656 100644 --- a/apps/web/src/i18n/locales/zh-TW.json +++ b/apps/web/src/i18n/locales/zh-TW.json @@ -2941,7 +2941,24 @@ "alias_error_too_long": "別名不能超過 120 個字元", "alias_error_duplicate": "別名已存在,請換一個唯一別名", "error_empty": "請輸入 API 金鑰", - "error_invalid": "API 金鑰包含無效字元" + "error_invalid": "API 金鑰包含無效字元", + "policy_action": "權限與額度", + "policy_title": "{{name}} 的權限與額度", + "policy_save": "儲存策略", + "policy_enabled": "允許此 Key 發出請求", + "policy_identity_hint": "此策略直接綁定 CPA 原生 Key;外掛不會簽發或儲存另一把 Key。", + "policy_grants": "提供商與模型權限", + "policy_provider": "提供商(支援 *)", + "policy_model": "模型(支援 * 和 ?)", + "policy_add_grant": "新增權限", + "policy_grant_required": "至少需要一條完整的提供商與模型權限", + "policy_quotas": "額度限制", + "policy_rpm": "每分鐘請求數", + "policy_daily_calls": "每日呼叫次數", + "policy_weekly_calls": "每週呼叫次數", + "policy_daily_tokens": "每日 Token", + "policy_weekly_tokens": "每週 Token", + "policy_unlimited_hint": "數值為 0 表示不限制。策略也可透過 Management API 交易式批次更新。" }, "payload_rules": { "rule": "規則", diff --git a/apps/web/src/services/api/index.ts b/apps/web/src/services/api/index.ts index 1a66a2a44..94cb737f9 100644 --- a/apps/web/src/services/api/index.ts +++ b/apps/web/src/services/api/index.ts @@ -16,3 +16,4 @@ export * from './transformers'; export * from './vertex'; export * from './codexQuota'; export * from './antigravitySubscription'; +export * from './nativeKeyPolicy'; diff --git a/apps/web/src/services/api/nativeKeyPolicy.ts b/apps/web/src/services/api/nativeKeyPolicy.ts new file mode 100644 index 000000000..e7367a0ae --- /dev/null +++ b/apps/web/src/services/api/nativeKeyPolicy.ts @@ -0,0 +1,52 @@ +import { apiClient } from './client'; + +export type NativeKeyGrant = { + provider: string; + model: string; +}; + +export type NativeKeyPolicy = { + key_hash: string; + enabled: boolean; + grants: NativeKeyGrant[]; + rpm?: number; + daily_calls?: number; + weekly_calls?: number; + daily_tokens?: number; + weekly_tokens?: number; +}; + +type PolicyListResponse = { + policies?: NativeKeyPolicy[]; +}; + +export const nativeKeyPolicyApi = { + async isNativeAccessAvailable(): Promise { + const response = await apiClient.get<{ mode?: string }>( + '/plugins/cpa-key-policy/status' + ); + return response?.mode === 'native-access'; + }, + + async list(): Promise { + const response = await apiClient.get( + '/plugins/cpa-key-policy/policies' + ); + return Array.isArray(response?.policies) ? response.policies : []; + }, + + save(policy: NativeKeyPolicy): Promise<{ policy: NativeKeyPolicy }> { + return apiClient.put('/plugins/cpa-key-policy/policies', policy); + }, + + applyBulk( + policies: NativeKeyPolicy[], + options: { mode?: 'merge' | 'replace'; dryRun?: boolean } = {} + ): Promise<{ policies: NativeKeyPolicy[]; mode: string; dry_run: boolean }> { + return apiClient.put('/plugins/cpa-key-policy/policies/bulk', { + policies, + mode: options.mode ?? 'merge', + dry_run: options.dryRun ?? false + }); + } +};