fix(auth): restrict credential actions to OAuth files
This commit is contained in:
1 parent
efe9b4b936
commit
892b14c2ab
9 files changed
+156
-27
No files matched your search
+5
-4
@@ -854,7 +854,7 @@ export const jaOverrides = {
|
||||
'authFiles.quota.resets': '手動リセット {count} 回',
|
||||
'authFiles.quota.expiry': '最も早い有効期限 {time}',
|
||||
'authFiles.models.noneForProvider': 'このプロバイダーには設定可能なモデル定義がありません',
|
||||
'authFiles.models.updated': '{provider} 全体のモデル設定を更新しました',
|
||||
'authFiles.models.updated': '{provider} の OAuth 認証情報のモデル除外ルールを更新しました',
|
||||
'authFiles.priority.button': '優先度 {priority}',
|
||||
'authFiles.priority.title': '認証情報の優先度を変更',
|
||||
'authFiles.priority.label': '優先度',
|
||||
@@ -901,9 +901,10 @@ export const jaOverrides = {
|
||||
'authFiles.models.title': 'アカウントのモデル除外',
|
||||
'authFiles.models.description': '{provider} · チェックしたモデルをこのアカウントで除外します。全体のルールも適用されます',
|
||||
'authFiles.models.credentialUpdated': '{name} のモデル除外ルールを更新しました',
|
||||
'authFiles.models.globalButton': 'OAuth 全体のモデル除外',
|
||||
'authFiles.models.provider': 'プロバイダー',
|
||||
'authFiles.models.globalDescription': 'チェックしたモデルをすべての {provider} OAuth アカウントで除外します。個別のルールも適用されます',
|
||||
'authFiles.models.globalButton': 'OAuth 認証情報のモデル除外',
|
||||
'authFiles.models.provider': 'OAuth プロバイダー',
|
||||
'authFiles.models.globalDescription': 'チェックしたモデルはすべての {provider} OAuth 認証情報ファイルでのみ除外され、API 接続には影響しません。認証情報ごとのルールも適用されます',
|
||||
'authFiles.fileOnly': 'この操作は OAuth 認証情報ファイルのみが対象です。API 接続は「API 接続」ページで管理してください。',
|
||||
'authFiles.models.runtimeUnsupported': '実行時の認証情報のモデルは、対応するプロバイダーまたは認証元で設定してください',
|
||||
'authFiles.models.invalidMetadata': '認証ファイルを読み取れないため、モデル設定を読み込めませんでした',
|
||||
'authFiles.models.invalidExclusions': '認証ファイルのモデル除外ルールが無効です。ファイルを確認してください',
|
||||
|
||||
@@ -854,7 +854,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'authFiles.quota.resets': '{count} manual resets',
|
||||
'authFiles.quota.expiry': 'Earliest expiry {time}',
|
||||
'authFiles.models.noneForProvider': 'This provider has no configurable model definitions',
|
||||
'authFiles.models.updated': 'Provider-wide models updated for {provider}',
|
||||
'authFiles.models.updated': 'OAuth credential model exclusions updated for {provider}',
|
||||
'authFiles.priority.button': 'Priority {priority}',
|
||||
'authFiles.priority.title': 'Edit Credential Priority',
|
||||
'authFiles.priority.label': 'Priority',
|
||||
@@ -901,9 +901,10 @@ export const en: Record<MessageKey, string> = {
|
||||
'authFiles.models.title': 'Account Model Exclusions',
|
||||
'authFiles.models.description': '{provider} · Checked models are excluded by this account; provider-wide rules also apply',
|
||||
'authFiles.models.credentialUpdated': 'Model exclusions updated for {name}',
|
||||
'authFiles.models.globalButton': 'OAuth Global Model Exclusions',
|
||||
'authFiles.models.provider': 'Provider',
|
||||
'authFiles.models.globalDescription': 'Checked models are excluded for every {provider} OAuth account; account-specific rules still apply',
|
||||
'authFiles.models.globalButton': 'OAuth Credential Model Exclusions',
|
||||
'authFiles.models.provider': 'OAuth Provider',
|
||||
'authFiles.models.globalDescription': 'Checked models are excluded only for all {provider} OAuth credential files, not API Access entries; credential-specific rules still apply',
|
||||
'authFiles.fileOnly': 'This action only supports OAuth credential files. Manage API Access entries on the API Access page.',
|
||||
'authFiles.models.runtimeUnsupported': 'Manage models for runtime credentials in their provider or authentication source',
|
||||
'authFiles.models.invalidMetadata': 'Could not read the credential file; model settings were not loaded',
|
||||
'authFiles.models.invalidExclusions': 'The credential file has invalid model exclusion rules; check the file first',
|
||||
|
||||
@@ -853,7 +853,7 @@ export const zhCN = {
|
||||
'authFiles.quota.resets': '主动重置 {count} 次',
|
||||
'authFiles.quota.expiry': '最早过期 {time}',
|
||||
'authFiles.models.noneForProvider': '该提供商没有可配置的模型定义',
|
||||
'authFiles.models.updated': '{provider} 渠道全局模型已更新',
|
||||
'authFiles.models.updated': '{provider} OAuth 凭证模型禁用规则已更新',
|
||||
'authFiles.priority.button': '优先级 {priority}',
|
||||
'authFiles.priority.title': '修改凭证优先级',
|
||||
'authFiles.priority.label': '优先级',
|
||||
@@ -900,9 +900,10 @@ export const zhCN = {
|
||||
'authFiles.models.title': '账号排除模型',
|
||||
'authFiles.models.description': '{provider} · 勾选表示此账号主动排除;渠道全局规则仍会叠加生效',
|
||||
'authFiles.models.credentialUpdated': '{name} 的模型排除规则已更新',
|
||||
'authFiles.models.globalButton': 'OAuth 全局模型禁用',
|
||||
'authFiles.models.provider': '渠道',
|
||||
'authFiles.models.globalDescription': '勾选将禁用全部 {provider} OAuth 账号的对应模型;账号自身规则仍然生效',
|
||||
'authFiles.models.globalButton': 'OAuth 凭证模型禁用',
|
||||
'authFiles.models.provider': 'OAuth 渠道',
|
||||
'authFiles.models.globalDescription': '勾选仅禁用全部 {provider} OAuth 凭证文件的对应模型,不影响 API 接入;凭证自身规则仍然生效',
|
||||
'authFiles.fileOnly': '此操作仅支持 OAuth 凭证文件;API 接入请在“API 接入”页面管理。',
|
||||
'authFiles.models.runtimeUnsupported': '运行时凭据请在对应提供商或认证源中设置模型',
|
||||
'authFiles.models.invalidMetadata': '无法读取凭证文件内容,未加载模型设置',
|
||||
'authFiles.models.invalidExclusions': '凭证文件的模型排除规则格式无效,请先检查该文件',
|
||||
|
||||
@@ -55,8 +55,11 @@ import {
|
||||
import {
|
||||
authFileName,
|
||||
dedupeAuthFiles,
|
||||
isOAuthCredentialFile,
|
||||
isRuntimeOnlyAuthFile,
|
||||
oauthModelProvidersFromAuthFiles,
|
||||
parseAuthFilePriority,
|
||||
setOAuthCredentialFileDisabled,
|
||||
} from '../services/authFiles';
|
||||
import {
|
||||
modelMatchesRule,
|
||||
@@ -140,12 +143,9 @@ export function AuthFileManagementPage() {
|
||||
const oauthModels = oauthModelSettings?.models ?? [];
|
||||
const oauthExcludedRules = normalizeOAuthExcludedRules(oauthExcludedRulesText.split(/\r?\n/));
|
||||
const excludedOauthModelCount = oauthModels.length - openOAuthModelNames(oauthModels, oauthExcludedRules).size;
|
||||
const oauthModelProviders = useMemo(() => Array.from(new Map(
|
||||
files.filter((file) => providerKey(file)).map((file) => [providerKey(file), {
|
||||
provider: providerKey(file),
|
||||
label: providerName(file),
|
||||
}]),
|
||||
).values()).sort((a, b) => a.label.localeCompare(b.label)), [files]);
|
||||
const oauthModelProviders = useMemo(() => oauthModelProvidersFromAuthFiles(files)
|
||||
.map((provider) => ({ provider, label: providerName({ provider }) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)), [files]);
|
||||
|
||||
const loadFiles = useCallback(async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true);
|
||||
@@ -306,15 +306,11 @@ export function AuthFileManagementPage() {
|
||||
};
|
||||
|
||||
const toggleStatus = async (file: AuthFile) => {
|
||||
const name = fileName(file);
|
||||
feedback.clearNotice();
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await managementApi.patch('/auth-files/status', {
|
||||
name,
|
||||
disabled: !readBoolean(file, 'disabled'),
|
||||
});
|
||||
await setOAuthCredentialFileDisabled(file, !readBoolean(file, 'disabled'));
|
||||
await loadFiles(false);
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
@@ -447,10 +443,10 @@ export function AuthFileManagementPage() {
|
||||
</div>
|
||||
{note ? <div className="auth-card-note"><span>{t('authFiles.settings.note')}</span><p title={note}>{note}</p></div> : null}
|
||||
<footer className="auth-card-actions">
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => setSettingsName(name)} disabled={busy || isRuntimeOnly(file) || !readString(file, 'name')} title={t(isRuntimeOnly(file) ? 'authFiles.settings.runtime' : 'authFiles.settings.title')}><Settings2 size={14} />{t('authFiles.settings.button')}</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => setSettingsName(name)} disabled={busy || !isOAuthCredentialFile(file)} title={t(isOAuthCredentialFile(file) ? 'authFiles.settings.title' : 'authFiles.fileOnly')}><Settings2 size={14} />{t('authFiles.settings.button')}</button>
|
||||
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => setModelViewName(name)} disabled={busy} title={t('authFiles.models.viewTitle')}>{t('authFiles.models.button')}</button> : null}
|
||||
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy} title={t('authFiles.copyName')}>{copied === name ? <Check size={15} /> : <Copy size={15} />}</button>
|
||||
<button type="button" className={`${disabled ? 'primary-button' : 'secondary-button'} compact-button auth-card-toggle`} onClick={() => void toggleStatus(file)} disabled={busy}>{disabled ? t('common.enable') : t('common.disable')}</button>
|
||||
<button type="button" className={`${disabled ? 'primary-button' : 'secondary-button'} compact-button auth-card-toggle`} onClick={() => void toggleStatus(file)} disabled={busy || !isOAuthCredentialFile(file)} title={isOAuthCredentialFile(file) ? undefined : t('authFiles.fileOnly')}>{disabled ? t('common.enable') : t('common.disable')}</button>
|
||||
<button type="button" className="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.delete')}><Trash2 size={15} /></button>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readBoolean, readString } from './managementApi';
|
||||
import { managementApi, readBoolean, readString } from './managementApi';
|
||||
import { getCurrentLocale, translate } from '../i18n';
|
||||
|
||||
export type AuthFileRecord = Record<string, unknown>;
|
||||
@@ -10,6 +10,26 @@ export const authFileName = (file: AuthFileRecord) =>
|
||||
export const isRuntimeOnlyAuthFile = (file: AuthFileRecord) =>
|
||||
readBoolean(file, 'runtime_only', 'runtimeOnly');
|
||||
|
||||
export const isOAuthCredentialFile = (file: AuthFileRecord): boolean => {
|
||||
if (!readString(file, 'name').toLowerCase().endsWith('.json') || isRuntimeOnlyAuthFile(file)) return false;
|
||||
const kinds = ['account_type', 'auth_kind', 'authKind'].map((key) =>
|
||||
readString(file, key).toLowerCase().replace(/[-_]/g, ''));
|
||||
if (kinds.includes('apikey')) return false;
|
||||
const source = readString(file, 'source').toLowerCase();
|
||||
return !source || source === 'file';
|
||||
};
|
||||
|
||||
export const setOAuthCredentialFileDisabled = async (
|
||||
file: AuthFileRecord,
|
||||
disabled: boolean,
|
||||
api: { patch: (path: string, body: Record<string, unknown>) => Promise<unknown> } = managementApi,
|
||||
): Promise<void> => {
|
||||
if (!isOAuthCredentialFile(file)) {
|
||||
throw new Error(translate(getCurrentLocale(), 'authFiles.fileOnly'));
|
||||
}
|
||||
await api.patch('/auth-files/status', { name: readString(file, 'name'), disabled });
|
||||
};
|
||||
|
||||
export const parseAuthFilePriority = (value: unknown): number | undefined => {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isSafeInteger(value) ? value : undefined;
|
||||
@@ -31,6 +51,7 @@ const normalizeOAuthProvider = (value: string) => {
|
||||
const provider = value.trim().toLowerCase();
|
||||
if (provider === 'cognition') return 'devin';
|
||||
if (provider === 'anthropic') return 'claude';
|
||||
if (provider === 'anti-gravity') return 'antigravity';
|
||||
if (provider === 'openai') return 'codex';
|
||||
return provider;
|
||||
};
|
||||
@@ -38,6 +59,9 @@ const normalizeOAuthProvider = (value: string) => {
|
||||
const authFileProvider = (file: AuthFileRecord) =>
|
||||
normalizeOAuthProvider(readString(file, 'provider', 'type'));
|
||||
|
||||
export const oauthModelProvidersFromAuthFiles = (files: AuthFileRecord[]): string[] =>
|
||||
[...new Set(files.filter(isOAuthCredentialFile).map(authFileProvider).filter(Boolean))].sort();
|
||||
|
||||
export const snapshotAuthFiles = (files: AuthFileRecord[]): AuthFileSnapshot => {
|
||||
const grouped = new Map<string, string[]>();
|
||||
files.forEach((file) => {
|
||||
@@ -99,6 +123,10 @@ const authFilePriority = (file: AuthFileRecord) => {
|
||||
return score;
|
||||
};
|
||||
|
||||
const authFileSourceFields = new Set([
|
||||
'source', 'path', 'runtime_only', 'runtimeOnly', 'account_type', 'auth_kind', 'authKind',
|
||||
]);
|
||||
|
||||
const mergeDuplicateAuthFiles = (entries: AuthFileRecord[]) => {
|
||||
const sorted = [...entries].sort((left, right) => {
|
||||
const priority = authFilePriority(right) - authFilePriority(left);
|
||||
@@ -111,6 +139,7 @@ const mergeDuplicateAuthFiles = (entries: AuthFileRecord[]) => {
|
||||
const merged = { ...sorted[0] };
|
||||
sorted.slice(1).forEach((entry) => {
|
||||
Object.entries(entry).forEach(([key, value]) => {
|
||||
if (authFileSourceFields.has(key)) return;
|
||||
if (key === 'cooldowns' && Object.prototype.hasOwnProperty.call(merged, key)) return;
|
||||
if (!hasMeaningfulValue(merged[key]) && hasMeaningfulValue(value)) merged[key] = value;
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user