From 492aa75e8106a9133cf3b74be5a6b326c814e67b Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 7 Sep 2026 15:35:12 +0800 Subject: [PATCH] fix(auth-files): separate account and provider-wide model exclusions - Align model management with Management Center by separating model viewing, account exclusions, and provider-wide exclusions - Read and update excluded_models per credential without affecting other accounts - Make checked models represent exclusions and support manual wildcard rules - Limit Select All to candidate models and preserve wildcard and off-list rules when clearing selections - Allow rule editing when the model list is empty or unavailable, including removal of wildcard bans - Update translations and add regression coverage for account isolation and rule persistence --- src/components/AuthFileModelsDialog.tsx | 87 ++++++++++ src/i18n/ja.ts | 41 +++-- src/i18n/locales/en.ts | 41 +++-- src/i18n/locales/zh-CN.ts | 41 +++-- src/pages/AuthFileManagementPage.tsx | 208 ++++++++++++++---------- src/services/oauthModelSettings.ts | 100 ++++++++++++ src/services/oauthModels.ts | 44 ++--- src/styles.css | 99 +++++++++++ tests/oauthModelSettings.test.ts | 172 ++++++++++++++++++++ tests/oauthModels.test.ts | 54 +++--- 10 files changed, 720 insertions(+), 167 deletions(-) create mode 100644 src/components/AuthFileModelsDialog.tsx create mode 100644 src/services/oauthModelSettings.ts create mode 100644 tests/oauthModelSettings.test.ts diff --git a/src/components/AuthFileModelsDialog.tsx b/src/components/AuthFileModelsDialog.tsx new file mode 100644 index 0000000..be50091 --- /dev/null +++ b/src/components/AuthFileModelsDialog.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from 'react'; +import { Check, Copy, LoaderCircle, Search, X } from 'lucide-react'; +import { useI18n } from '../i18n'; +import { managementApi } from '../services/managementApi'; +import { oauthModelsFromPayload, type OAuthModelDefinition } from '../services/oauthModels'; + +type AuthFileModelsDialogProps = { + name: string; + onClose: () => void; +}; + +export function AuthFileModelsDialog({ name, onClose }: AuthFileModelsDialogProps) { + const { t } = useI18n(); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [search, setSearch] = useState(''); + const [copied, setCopied] = useState(''); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setModels([]); + setError(''); + void managementApi.get('/auth-files/models', { name }) + .then((payload) => { if (!cancelled) setModels(oauthModelsFromPayload(payload)); }) + .catch((requestError: unknown) => { if (!cancelled) setError(String(requestError)); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [name]); + + const query = search.trim().toLowerCase(); + const visibleModels = models.filter((model) => + [model.id, model.displayName ?? ''].join(' ').toLowerCase().includes(query), + ); + const copyModel = async (id: string) => { + try { + await navigator.clipboard.writeText(id); + setCopied(id); + } catch (copyError) { + setError(String(copyError)); + } + }; + + return ( +
{ if (event.currentTarget === event.target) onClose(); }}> +
{ if (event.key === 'Escape') onClose(); }}> +
+
+

{t('authFiles.models.viewTitle')}

+ {name} + {t('authFiles.models.viewDescription')} +
+ +
+
+
+
+ {loading ? ( +
{t('authFiles.models.loading')}
+ ) : ( +
+
{error ?
{error}
: null}
+ {visibleModels.length === 0 ? ( +
{t(models.length ? 'authFiles.models.noMatch' : 'authFiles.models.viewEmpty')}
+ ) : ( +
+ {visibleModels.map((model) => ( + + ))} +
+ )} +
+ )} +
+
+ +
+
+
+ ); +} diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index f6eff29..e1118d7 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -614,7 +614,7 @@ export const jaOverrides = { 'authFiles.quota.resets': '手動リセット {count} 回', 'authFiles.quota.expiry': '最も早い有効期限 {time}', 'authFiles.models.noneForProvider': 'このプロバイダーには設定可能なモデル定義がありません', - 'authFiles.models.updated': '{provider} の公開モデルを更新しました', + 'authFiles.models.updated': '{provider} 全体のモデル設定を更新しました', 'authFiles.priority.button': '優先度 {priority}', 'authFiles.priority.title': '認証情報の優先度を変更', 'authFiles.priority.label': '優先度', @@ -651,27 +651,44 @@ export const jaOverrides = { 'authFiles.quota.querying': '照会中', 'authFiles.quota.refresh': 'クォータを更新', 'authFiles.quota.fetch': 'クォータを取得', - 'authFiles.models.settings': '公開モデルを設定', + 'authFiles.models.settings': 'このアカウントのモデル除外を編集', 'authFiles.models.button': 'モデル', 'authFiles.copyName': 'ファイル名をコピー', 'authFiles.openDirectory': 'フォルダーを開く', 'common.enable': '有効化', 'common.disable': '無効化', 'authFiles.runtimeFootnote': '{count} 件の認証情報は実行時ストレージ由来です。対応するプロバイダーまたは認証元でのみ管理できます。', - 'authFiles.models.title': '公開モデル', - 'authFiles.models.description': '{provider} · 既定ではすべて公開されます。不要なモデルは選択解除できます', + '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.runtimeUnsupported': '実行時の認証情報のモデルは、対応するプロバイダーまたは認証元で設定してください', + 'authFiles.models.invalidMetadata': '認証ファイルを読み取れないため、モデル設定を読み込めませんでした', + 'authFiles.models.invalidExclusions': '認証ファイルのモデル除外ルールが無効です。ファイルを確認してください', + 'authFiles.models.excludeButton': 'モデル除外', + 'authFiles.models.viewTitle': '対応モデル', + 'authFiles.models.viewDescription': 'この認証情報に現在登録されているモデルです。モデル名をクリックするとコピーできます', + 'authFiles.models.viewEmpty': 'この認証情報で現在利用できるモデルはありません', + 'authFiles.models.copyModel': 'モデル名をコピー', + 'authFiles.models.excludeAll': 'すべて除外', + 'authFiles.models.excludeAllHint': '候補一覧の各モデルを追加します。* は自動で追加されません', + 'authFiles.models.clearSelected': '選択をクリア', + 'authFiles.models.clearHint': '一覧内の完全一致の選択を解除し、ワイルドカードと一覧外のルールを保持します', + 'authFiles.models.rulesPlaceholder': 'gpt-image-*', + 'authFiles.models.rulesLabel': '除外ルール(1 行に 1 件)', + 'authFiles.models.rulesHint': '* は任意の文字列に一致し、大文字と小文字は区別しません。* を入力するとすべてのモデルを除外します。選択のクリアではワイルドカードと一覧外のルールを保持します。', + 'authFiles.models.wildcardBlocked': '{rule} により除外されています。下のルールを編集してください', + 'authFiles.models.catalogUnavailable': '候補モデルを読み込めませんでした。下の欄で除外ルールの編集と保存は可能です', 'authFiles.models.search': 'モデル名を検索', - 'authFiles.models.summary': '合計 {total} 件 · 公開 {open} 件', - 'authFiles.models.closeVisible': '表示中を非公開', - 'authFiles.models.openVisible': '表示中を公開', - 'authFiles.models.openAll': 'すべて公開', - 'authFiles.models.closeAll': 'すべて非公開', - 'authFiles.models.loading': 'モデル定義を読み込んでいます', + 'authFiles.models.summary': '候補 {total} 件 · 除外 {excluded} 件', + 'authFiles.models.loading': 'モデル一覧を読み込んでいます', 'authFiles.models.loadFailed': 'モデルの読み込みに失敗しました', 'authFiles.models.noMatch': '一致するモデルがありません', - 'authFiles.models.empty': 'モデル定義がありません', + 'authFiles.models.empty': '候補モデルがありません。下の欄に除外ルールを入力できます', 'common.saving': '保存中', - 'authFiles.models.save': '保存({count} 件を公開)', + 'authFiles.models.save': '保存({count} ルール)', 'agents.description.claudeCode': 'CPA の Anthropic 互換 API を使用', 'agents.description.claudeDesktop': 'CPA 3P 推論ゲートウェイを使用(Windows、macOS、Linux Beta 対応)', 'agents.description.codex': 'CPA Responses API を使用', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index f0bab3d..908476c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -614,7 +614,7 @@ export const en: Record = { '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': 'Open models updated for {provider}', + 'authFiles.models.updated': 'Provider-wide models updated for {provider}', 'authFiles.priority.button': 'Priority {priority}', 'authFiles.priority.title': 'Edit Credential Priority', 'authFiles.priority.label': 'Priority', @@ -651,27 +651,44 @@ export const en: Record = { 'authFiles.quota.querying': 'Querying', 'authFiles.quota.refresh': 'Refresh Quota', 'authFiles.quota.fetch': 'Fetch Quota', - 'authFiles.models.settings': 'Configure open models', + 'authFiles.models.settings': 'Edit exclusions for this account', 'authFiles.models.button': 'Models', 'authFiles.copyName': 'Copy file name', 'authFiles.openDirectory': 'Open Folder', 'common.enable': 'Enable', 'common.disable': 'Disable', 'authFiles.runtimeFootnote': '{count} credentials come from runtime storage and can only be managed through their provider or authentication source.', - 'authFiles.models.title': 'Open Models', - 'authFiles.models.description': '{provider} · All models are open by default; disable any you do not need', + '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.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', + 'authFiles.models.excludeButton': 'Exclude Models', + 'authFiles.models.viewTitle': 'Supported Models', + 'authFiles.models.viewDescription': 'Models currently registered for this credential; click a model name to copy it', + 'authFiles.models.viewEmpty': 'This credential currently has no available models', + 'authFiles.models.copyModel': 'Copy model name', + 'authFiles.models.excludeAll': 'Exclude All', + 'authFiles.models.excludeAllHint': 'Add each model in the candidate list; this does not automatically add *', + 'authFiles.models.clearSelected': 'Clear Selection', + 'authFiles.models.clearHint': 'Clear exact selections in the list, preserving wildcard and off-list rules', + 'authFiles.models.rulesPlaceholder': 'gpt-image-*', + 'authFiles.models.rulesLabel': 'Exclusion rules (one per line)', + 'authFiles.models.rulesHint': '* matches any characters; matching is case-insensitive. Enter * to exclude all models. Clearing selections preserves wildcard and off-list rules.', + 'authFiles.models.wildcardBlocked': 'Excluded by {rule}; edit the rule below', + 'authFiles.models.catalogUnavailable': 'Could not load candidate models. You can still edit and save exclusion rules below', 'authFiles.models.search': 'Search model names', - 'authFiles.models.summary': '{total} total · {open} open', - 'authFiles.models.closeVisible': 'Close Visible', - 'authFiles.models.openVisible': 'Open Visible', - 'authFiles.models.openAll': 'Open All', - 'authFiles.models.closeAll': 'Close All', - 'authFiles.models.loading': 'Loading model definitions', + 'authFiles.models.summary': '{total} candidates · {excluded} excluded', + 'authFiles.models.loading': 'Loading model list', 'authFiles.models.loadFailed': 'Failed to load models', 'authFiles.models.noMatch': 'No matching models', - 'authFiles.models.empty': 'No model definitions', + 'authFiles.models.empty': 'No candidate models; enter exclusion rules below', 'common.saving': 'Saving', - 'authFiles.models.save': 'Save ({count} open)', + 'authFiles.models.save': 'Save ({count} rules)', 'agents.description.claudeCode': 'Uses the CPA Anthropic-compatible API', 'agents.description.claudeDesktop': 'Uses the CPA third-party inference gateway on Windows, macOS, and Linux Beta', 'agents.description.codex': 'Uses the CPA Responses API', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index bf0ada2..b82f21c 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -613,7 +613,7 @@ export const zhCN = { 'authFiles.quota.resets': '主动重置 {count} 次', 'authFiles.quota.expiry': '最早过期 {time}', 'authFiles.models.noneForProvider': '该提供商没有可配置的模型定义', - 'authFiles.models.updated': '{provider} 开放模型已更新', + 'authFiles.models.updated': '{provider} 渠道全局模型已更新', 'authFiles.priority.button': '优先级 {priority}', 'authFiles.priority.title': '修改凭证优先级', 'authFiles.priority.label': '优先级', @@ -650,27 +650,44 @@ export const zhCN = { 'authFiles.quota.querying': '查询中', 'authFiles.quota.refresh': '刷新额度', 'authFiles.quota.fetch': '获取额度', - 'authFiles.models.settings': '设置开放模型', + 'authFiles.models.settings': '编辑此账号的排除模型', 'authFiles.models.button': '模型', 'authFiles.copyName': '复制文件名', 'authFiles.openDirectory': '打开文件夹', 'common.enable': '启用', 'common.disable': '停用', 'authFiles.runtimeFootnote': '{count} 个凭据来自运行时存储,只能在对应提供商或认证源中管理。', - 'authFiles.models.title': '开放模型', - 'authFiles.models.description': '{provider} · 默认全部开放,可取消不需要的模型', + '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.runtimeUnsupported': '运行时凭据请在对应提供商或认证源中设置模型', + 'authFiles.models.invalidMetadata': '无法读取凭证文件内容,未加载模型设置', + 'authFiles.models.invalidExclusions': '凭证文件的模型排除规则格式无效,请先检查该文件', + 'authFiles.models.excludeButton': '排除模型', + 'authFiles.models.viewTitle': '支持的模型', + 'authFiles.models.viewDescription': '显示此凭证当前注册的模型;点击模型名可复制', + 'authFiles.models.viewEmpty': '此凭证当前没有可用模型', + 'authFiles.models.copyModel': '复制模型名', + 'authFiles.models.excludeAll': '全部排除', + 'authFiles.models.excludeAllHint': '将当前候选列表中的模型逐个加入排除规则,不会自动写入 *', + 'authFiles.models.clearSelected': '清空勾选', + 'authFiles.models.clearHint': '清空列表内的精确勾选,保留通配符和列表外规则', + 'authFiles.models.rulesPlaceholder': 'gpt-image-*', + 'authFiles.models.rulesLabel': '排除规则(每行一条)', + 'authFiles.models.rulesHint': '* 匹配任意字符,匹配不区分大小写。填写 * 可排除所有模型;清空勾选会保留通配符和列表外规则。', + 'authFiles.models.wildcardBlocked': '由 {rule} 排除,请在下方规则框中修改', + 'authFiles.models.catalogUnavailable': '候选模型加载失败,仍可在下方编辑和保存排除规则', 'authFiles.models.search': '搜索模型名称', - 'authFiles.models.summary': '共 {total} 个 · 已开放 {open} 个', - 'authFiles.models.closeVisible': '关闭当前', - 'authFiles.models.openVisible': '开放当前', - 'authFiles.models.openAll': '全部开放', - 'authFiles.models.closeAll': '全部关闭', - 'authFiles.models.loading': '正在读取模型定义', + 'authFiles.models.summary': '候选 {total} 个 · 已排除 {excluded} 个', + 'authFiles.models.loading': '正在读取模型列表', 'authFiles.models.loadFailed': '读取模型失败', 'authFiles.models.noMatch': '没有匹配的模型', - 'authFiles.models.empty': '暂无模型定义', + 'authFiles.models.empty': '暂无候选模型,可在下方填写排除规则', 'common.saving': '保存中', - 'authFiles.models.save': '保存(开放 {count} 个)', + 'authFiles.models.save': '保存({count} 条规则)', 'agents.description.claudeCode': '使用 CPA 的 Anthropic 兼容接口', 'agents.description.claudeDesktop': '使用 CPA 3P 推理网关,支持 Windows、macOS 和 Linux Beta', 'agents.description.codex': '使用 CPA Responses 接口', diff --git a/src/pages/AuthFileManagementPage.tsx b/src/pages/AuthFileManagementPage.tsx index 5d00e3a..caee7e8 100644 --- a/src/pages/AuthFileManagementPage.tsx +++ b/src/pages/AuthFileManagementPage.tsx @@ -1,6 +1,7 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { confirm } from '@tauri-apps/plugin-dialog'; import { InlineNotice, useAppNotice } from '../appNotice'; +import { AuthFileModelsDialog } from '../components/AuthFileModelsDialog'; import { Check, Copy, @@ -11,6 +12,7 @@ import { Pencil, RefreshCw, Search, + Settings2, Trash2, X, } from 'lucide-react'; @@ -53,12 +55,18 @@ import { parseAuthFilePriority, } from '../services/authFiles'; import { - exclusionsForOpenOAuthModels, - oauthExcludedRulesFromPayload, - oauthModelsFromPayload, + modelMatchesRule, + normalizeOAuthExcludedRules, openOAuthModelNames, + setOAuthModelsExcluded, type OAuthModelDefinition, } from '../services/oauthModels'; +import { + loadOAuthModelSettings, + saveOAuthModelSettings, + type OAuthModelSettings, + type OAuthModelTarget, +} from '../services/oauthModelSettings'; import { getCurrentLocale, translate, useI18n } from '../i18n'; import { formatQuotaReset, useQuotaClock } from '../services/quotaTime'; @@ -166,11 +174,10 @@ export function AuthFileManagementPage() { const { showNotice } = feedback; const [copied, setCopied] = useState(''); const [priorityEditor, setPriorityEditor] = useState(null); - const [oauthModelProvider, setOauthModelProvider] = useState(''); - const [oauthModelProviderLabel, setOauthModelProviderLabel] = useState(''); - const [oauthModels, setOauthModels] = useState([]); - const [oauthExcludedRules, setOauthExcludedRules] = useState([]); - const [openOauthModelNames, setOpenOauthModelNames] = useState>(new Set()); + const [oauthModelTarget, setOauthModelTarget] = useState(null); + const [oauthModelSettings, setOauthModelSettings] = useState(null); + const [oauthExcludedRulesText, setOauthExcludedRulesText] = useState(''); + const [modelViewName, setModelViewName] = useState(null); const [oauthModelSearch, setOauthModelSearch] = useState(''); const [oauthModelLoading, setOauthModelLoading] = useState(false); const [oauthModelSaving, setOauthModelSaving] = useState(false); @@ -178,6 +185,16 @@ export function AuthFileManagementPage() { const quotas = useQuotaCache(); const fileInputRef = useRef(null); const oauthModelRequestRef = useRef(0); + const oauthModelSaveRef = useRef(false); + 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 loadFiles = useCallback(async (showLoading = true) => { if (showLoading) setLoading(true); @@ -218,35 +235,27 @@ export function AuthFileManagementPage() { }; const closeOauthModels = () => { + if (oauthModelSaveRef.current) return; oauthModelRequestRef.current += 1; - setOauthModelProvider(''); + setOauthModelTarget(null); + setOauthModelSettings(null); }; - const openOauthModels = async (file: AuthFile) => { - const provider = providerKey(file); - if (!provider) return; + const openOauthModelSettings = async (target: OAuthModelTarget) => { + if (oauthModelSaveRef.current) return; const requestId = oauthModelRequestRef.current + 1; oauthModelRequestRef.current = requestId; - setOauthModelProvider(provider); - setOauthModelProviderLabel(providerName(file)); - setOauthModels([]); - setOauthExcludedRules([]); - setOpenOauthModelNames(new Set()); + setOauthModelTarget(target); + setOauthModelSettings(null); + setOauthExcludedRulesText(''); setOauthModelSearch(''); setOauthModelError(''); setOauthModelLoading(true); try { - const [definitionsPayload, excludedPayload] = await Promise.all([ - managementApi.get(`/model-definitions/${encodeURIComponent(provider)}`), - managementApi.get('/oauth-excluded-models'), - ]); + const settings = await loadOAuthModelSettings(target); if (oauthModelRequestRef.current !== requestId) return; - const models = oauthModelsFromPayload(definitionsPayload); - const excludedRules = oauthExcludedRulesFromPayload(excludedPayload, provider); - setOauthModels(models); - setOauthExcludedRules(excludedRules); - setOpenOauthModelNames(openOAuthModelNames(models, excludedRules)); - if (models.length === 0) setOauthModelError(t('authFiles.models.noneForProvider')); + setOauthModelSettings(settings); + setOauthExcludedRulesText(settings.excludedRules.join('\n')); } catch (requestError) { if (oauthModelRequestRef.current === requestId) setOauthModelError(String(requestError)); } finally { @@ -254,27 +263,31 @@ export function AuthFileManagementPage() { } }; + const openOauthModels = (file: AuthFile) => { + const name = readString(file, 'name'); + const provider = providerKey(file); + if (!name || !provider || isRuntimeOnly(file)) return; + return openOauthModelSettings({ scope: 'credential', name, provider, label: providerName(file) }); + }; + const saveOauthModels = async () => { - if (!oauthModelProvider) return; - const excludedModels = exclusionsForOpenOAuthModels( - oauthExcludedRules, - oauthModels, - openOauthModelNames, - ); + if (!oauthModelSettings || oauthModelLoading || oauthModelSaveRef.current) return; + const settings = oauthModelSettings; + oauthModelSaveRef.current = true; setOauthModelSaving(true); setOauthModelError(''); try { - if (excludedModels.length > 0 || oauthExcludedRules.length > 0) { - await managementApi.patch('/oauth-excluded-models', { - provider: oauthModelProvider, - models: excludedModels, - }); - } - showNotice({ key: 'authFiles.models.updated', variables: { provider: oauthModelProviderLabel } }); + await saveOAuthModelSettings(settings, oauthExcludedRules); + showNotice(settings.target.scope === 'credential' + ? { key: 'authFiles.models.credentialUpdated', variables: { name: settings.target.name } } + : { key: 'authFiles.models.updated', variables: { provider: settings.target.label } }); + oauthModelSaveRef.current = false; closeOauthModels(); + if (settings.target.scope === 'credential') void loadFiles(false); } catch (requestError) { setOauthModelError(String(requestError)); } finally { + oauthModelSaveRef.current = false; setOauthModelSaving(false); } }; @@ -287,29 +300,11 @@ export function AuthFileManagementPage() { ); }, [oauthModelSearch, oauthModels]); - const allVisibleOauthModelsOpen = visibleOauthModels.length > 0 - && visibleOauthModels.every((model) => openOauthModelNames.has(model.id.toLowerCase())); - - const toggleOauthModel = (model: OAuthModelDefinition) => { - const key = model.id.toLowerCase(); - setOpenOauthModelNames((current) => { - const next = new Set(current); - if (next.has(key)) next.delete(key); - else next.add(key); - return next; - }); - }; - - const toggleAllVisibleOauthModels = () => { - setOpenOauthModelNames((current) => { - const next = new Set(current); - visibleOauthModels.forEach((model) => { - const key = model.id.toLowerCase(); - if (allVisibleOauthModelsOpen) next.delete(key); - else next.add(key); - }); - return next; - }); + const setOauthModelsExcluded = (models: OAuthModelDefinition[], excluded: boolean) => { + if (oauthModelSaveRef.current) return; + setOauthExcludedRulesText((current) => + setOAuthModelsExcluded(current.split(/\r?\n/), models, excluded).join('\n'), + ); }; useEffect(() => { @@ -484,6 +479,12 @@ export function AuthFileManagementPage() {
{t('authFiles.summary', { files: files.length, disabled: disabledCount })} + @@ -541,7 +542,8 @@ export function AuthFileManagementPage() {
{quotaProviderForFile(file) ? : null} - {providerKey(file) ? : null} + {providerKey(file) ? : null} + {providerKey(file) ? : null} @@ -604,54 +606,86 @@ export function AuthFileManagementPage() {
) : null} - {oauthModelProvider ? ( + {modelViewName ? setModelViewName(null)} /> : null} + + {oauthModelTarget ? (
event.currentTarget === event.target && !oauthModelSaving && closeOauthModels()}> -
+
{ if (event.key === 'Escape') closeOauthModels(); }}>
-

{t('authFiles.models.title')}

{t('authFiles.models.description', { provider: oauthModelProviderLabel })}
+
+

{t(oauthModelTarget.scope === 'credential' ? 'authFiles.models.title' : 'authFiles.models.globalButton')}

+ {oauthModelTarget.scope === 'credential' ? ( + {oauthModelTarget.name} + ) : ( + + )} + {t(oauthModelTarget.scope === 'credential' ? 'authFiles.models.description' : 'authFiles.models.globalDescription', { provider: oauthModelTarget.label })} +
- {t('authFiles.models.summary', { total: oauthModels.length, open: openOauthModelNames.size })} + {t('authFiles.models.summary', { total: oauthModels.length, excluded: excludedOauthModelCount })}
- - - + +
{oauthModelLoading ? (
{t('authFiles.models.loading')}
- ) : oauthModelError ? ( + ) : oauthModelError && !oauthModelSettings ? (
{t('authFiles.models.loadFailed')}{oauthModelError}
- ) : visibleOauthModels.length === 0 ? ( -
{oauthModels.length ? t('authFiles.models.noMatch') : t('authFiles.models.empty')}
) : ( -
- {visibleOauthModels.map((model) => { - const checked = openOauthModelNames.has(model.id.toLowerCase()); - return ( - - ); - })} +
+
+ {oauthModelError ?
{oauthModelError}
: null} + {oauthModelSettings?.catalogError ?
{t('authFiles.models.catalogUnavailable')}
: null} +
+ {visibleOauthModels.length === 0 ? ( +
{oauthModels.length ? t('authFiles.models.noMatch') : t('authFiles.models.empty')}
+ ) : ( +
+ {visibleOauthModels.map((model) => { + const wildcardRule = oauthExcludedRules.find((rule) => rule.includes('*') && modelMatchesRule(model.id, rule)); + const checked = oauthExcludedRules.some((rule) => modelMatchesRule(model.id, rule)); + return ( + + ); + })} +
+ )}
)}
+