Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 226 additions & 0 deletions apps/web/src/components/config/ApiKeyAccessPolicyModal.tsx
Original file line number Diff line number Diff line change
@@ -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<NativeKeyGrant[]>([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 (
<Modal
open={open}
onClose={onClose}
title={t('config_management.visual.api_keys.policy_title', { name: keyLabel })}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={saving}>
{t('config_management.visual.common.cancel')}
</Button>
<Button onClick={save} disabled={disabled || loading || saving}>
{t('config_management.visual.api_keys.policy_save')}
</Button>
</>
}
>
{loading ? <div className="hint">{t('common.loading')}</div> : null}
<div className="form-group">
<label>
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
disabled={disabled || loading || saving}
/>{' '}
{t('config_management.visual.api_keys.policy_enabled')}
</label>
<div className="hint">
{t('config_management.visual.api_keys.policy_identity_hint')}
</div>
</div>

<div className="form-group">
<label>{t('config_management.visual.api_keys.policy_grants')}</label>
{grants.map((grant, index) => (
<div
key={`${index}-${grant.provider}-${grant.model}`}
style={{ display: 'grid', gridTemplateColumns: '1fr 2fr auto', gap: 8, marginBottom: 8 }}
>
<input
className="input"
value={grant.provider}
placeholder={t('config_management.visual.api_keys.policy_provider')}
onChange={(event) => updateGrant(index, 'provider', event.target.value)}
disabled={disabled || loading || saving}
/>
<input
className="input"
value={grant.model}
placeholder={t('config_management.visual.api_keys.policy_model')}
onChange={(event) => updateGrant(index, 'model', event.target.value)}
disabled={disabled || loading || saving}
/>
<Button
variant="danger"
size="xs"
onClick={() =>
setGrants((previous) =>
previous.length === 1
? [emptyGrant()]
: previous.filter((_, grantIndex) => grantIndex !== index)
)
}
disabled={disabled || loading || saving}
>
{t('config_management.visual.common.delete')}
</Button>
</div>
))}
<Button
variant="secondary"
size="sm"
onClick={() => setGrants((previous) => [...previous, emptyGrant()])}
disabled={disabled || loading || saving}
>
{t('config_management.visual.api_keys.policy_add_grant')}
</Button>
</div>

<div className="form-group">
<label>{t('config_management.visual.api_keys.policy_quotas')}</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 8 }}>
{[
['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]) => (
<label key={String(label)}>
{t(`config_management.visual.api_keys.${String(label)}`)}
<input
className="input"
type="number"
min={0}
value={String(value)}
onChange={(event) => (setter as (next: string) => void)(event.target.value)}
disabled={disabled || loading || saving}
/>
</label>
))}
</div>
<div className="hint">{t('config_management.visual.api_keys.policy_unlimited_hint')}</div>
</div>
{error ? <div className="error-box">{error}</div> : null}
</Modal>
);
}
43 changes: 43 additions & 0 deletions apps/web/src/components/config/ApiKeysCardEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, ApiKeyAlias>();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -534,6 +554,19 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
</div>
<div className="item-actions">
{nativePolicyAvailable ? (
<Button
variant="secondary"
size="xs"
onClick={() => {
setPolicyKeyHash(apiKeyHash);
setPolicyKeyLabel(alias || maskApiKey(String(key || '')));
}}
disabled={disabled}
>
{t('config_management.visual.api_keys.policy_action')}
</Button>
) : null}
<Button
variant="secondary"
size="xs"
Expand Down Expand Up @@ -709,6 +742,16 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
)}
</div>
</Modal>
<ApiKeyAccessPolicyModal
open={Boolean(policyKeyHash)}
keyHash={policyKeyHash}
keyLabel={policyKeyLabel}
disabled={disabled}
onClose={() => {
setPolicyKeyHash('');
setPolicyKeyLabel('');
}}
/>
</div>
);
});
19 changes: 18 additions & 1 deletion apps/web/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 18 additions & 1 deletion apps/web/src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "Правило",
Expand Down
19 changes: 18 additions & 1 deletion apps/web/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "规则",
Expand Down
Loading