Files
EasyCLIProxyAPI/src/pages/AuthFileManagementPage.tsx
T
lzt404 fe83731b94 feat: enhance credential management with cards, settings, and quota controls
- Display credentials in responsive cards with quota progress bars
- Add request statistics and detailed health and cooldown information
- Consolidate nine credential settings into a dialog with partial updates
- Reuse Codex quota reset logic and confirmation across pages
- Move notes above card actions and hide the healthy status indicator
- Add translations and regression coverage
2026-09-20 18:09:32 +08:00

557 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useConfirmation } from '../components/ConfirmationDialog';
import { QuotaActionFeedback } from '../components/QuotaActionFeedback';
import { AuthFileQuotaPanel } from '../components/AuthFileQuotaPanel';
import { AuthFileSettingsDialog } from '../components/AuthFileSettingsDialog';
import { useCodexQuotaReset } from '../components/useCodexQuotaReset';
import './AuthFileManagementPage.css';
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
import { AuthFileModelsDialog } from '../components/AuthFileModelsDialog';
import { AuthFileRequestStatus } from '../components/AuthFileRequestStatus';
import { AuthFileHealthStatus } from '../components/AuthFileHealthStatus';
import {
Check,
Copy,
FileDown,
FolderOpen,
Import,
LoaderCircle,
RefreshCw,
Search,
Settings2,
Trash2,
X,
} from 'lucide-react';
import antigravityIcon from '../assets/icons/antigravity.svg';
import claudeIcon from '../assets/icons/claude.svg';
import codexIcon from '../assets/icons/codex.svg';
import geminiIcon from '../assets/icons/gemini.svg';
import grokIcon from '../assets/icons/grok.svg';
import devinIcon from '../assets/icons/devin.svg';
import kimiIcon from '../assets/icons/kimi-light.svg';
import vertexIcon from '../assets/icons/vertex.svg';
import {
formatDate,
managementApi,
readBoolean,
readNumber,
readString,
responseList,
} from '../services/managementApi';
import {
idleQuota,
loadQuota,
providerForFile as quotaProviderForFile,
quotaKey,
} from '../services/quotaService';
import {
captureQuotaCacheGeneration,
commitQuotaCacheIfCurrent,
getQuotaCacheSnapshot,
pruneQuotaCache,
updateQuotaCache,
useQuotaCache,
} from '../services/quotaCache';
import {
authFileName,
dedupeAuthFiles,
isRuntimeOnlyAuthFile,
parseAuthFilePriority,
} from '../services/authFiles';
import {
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';
type AuthFile = Record<string, unknown>;
const providerIcons: Record<string, string> = {
antigravity: antigravityIcon,
claude: claudeIcon,
codex: codexIcon,
gemini: geminiIcon,
kimi: kimiIcon,
vertex: vertexIcon,
xai: grokIcon,
devin: devinIcon,
};
const providerName = (file: AuthFile) => {
const value = readString(file, 'provider', 'type', 'account_type').toLowerCase();
if (value === 'anthropic') return 'Claude';
if (value === 'anti-gravity') return 'Antigravity';
if (value === 'xai') return 'xAI';
if (value === 'cognition') return 'Devin';
return value ? value.charAt(0).toUpperCase() + value.slice(1) : translate(getCurrentLocale(), 'authFiles.unknownProvider');
};
const providerKey = (file: AuthFile) => {
const value = readString(file, 'provider', 'type', 'account_type').toLowerCase();
if (value === 'cognition') return 'devin';
return value === 'anthropic' ? 'claude' : value === 'anti-gravity' ? 'antigravity' : value;
};
const fileName = authFileName;
const isRuntimeOnly = isRuntimeOnlyAuthFile;
export function AuthFileManagementPage() {
const { t } = useI18n();
const { askConfirmation, confirmationDialog } = useConfirmation();
const [fileSnapshot, setFileSnapshot] = useState<{
files: AuthFile[];
receivedAtMs: number;
observedAt?: string;
}>({ files: [], receivedAtMs: 0 });
const { files, receivedAtMs, observedAt } = fileSnapshot;
const [filter, setFilter] = useState('');
const [providerFilter, setProviderFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState<'all' | 'enabled' | 'disabled' | 'runtime'>('all');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const resetCodexQuota = useCodexQuotaReset(askConfirmation, setError);
const feedback = useAppNotice();
const { showNotice } = feedback;
const [copied, setCopied] = useState('');
const [settingsName, setSettingsName] = useState<string | null>(null);
const [oauthModelTarget, setOauthModelTarget] = useState<OAuthModelTarget | null>(null);
const [oauthModelSettings, setOauthModelSettings] = useState<OAuthModelSettings | null>(null);
const [oauthExcludedRulesText, setOauthExcludedRulesText] = useState('');
const [modelViewName, setModelViewName] = useState<string | null>(null);
const [oauthModelSearch, setOauthModelSearch] = useState('');
const [oauthModelLoading, setOauthModelLoading] = useState(false);
const [oauthModelSaving, setOauthModelSaving] = useState(false);
const [oauthModelError, setOauthModelError] = useState('');
const quotas = useQuotaCache();
const fileInputRef = useRef<HTMLInputElement | null>(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);
setError('');
try {
const payload = await managementApi.get('/auth-files');
const nextFiles = dedupeAuthFiles(responseList(payload, 'files'));
setFileSnapshot({ files: nextFiles, receivedAtMs: Date.now(), observedAt: readString(payload, 'observed_at') });
const validQuotaKeys = new Set(nextFiles.map(quotaKey));
pruneQuotaCache(validQuotaKeys);
updateQuotaCache((current) => {
const next = { ...current };
nextFiles.forEach((file) => {
const key = quotaKey(file);
if (!next[key]) next[key] = idleQuota();
});
return next;
});
} catch (requestError) {
setError(String(requestError));
} finally {
if (showLoading) setLoading(false);
}
}, []);
const refreshQuota = async (file: AuthFile) => {
if (readBoolean(file, 'disabled')) return;
const key = quotaKey(file);
if (getQuotaCacheSnapshot()[key]?.status === 'loading') return;
const cacheGeneration = captureQuotaCacheGeneration();
updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } }));
const result = await loadQuota(file);
commitQuotaCacheIfCurrent(cacheGeneration, () => {
updateQuotaCache((current) => ({ ...current, [key]: result }));
});
};
const closeOauthModels = () => {
if (oauthModelSaveRef.current) return;
oauthModelRequestRef.current += 1;
setOauthModelTarget(null);
setOauthModelSettings(null);
};
const openOauthModelSettings = async (target: OAuthModelTarget) => {
if (oauthModelSaveRef.current) return;
const requestId = oauthModelRequestRef.current + 1;
oauthModelRequestRef.current = requestId;
setOauthModelTarget(target);
setOauthModelSettings(null);
setOauthExcludedRulesText('');
setOauthModelSearch('');
setOauthModelError('');
setOauthModelLoading(true);
try {
const settings = await loadOAuthModelSettings(target);
if (oauthModelRequestRef.current !== requestId) return;
setOauthModelSettings(settings);
setOauthExcludedRulesText(settings.excludedRules.join('\n'));
} catch (requestError) {
if (oauthModelRequestRef.current === requestId) setOauthModelError(String(requestError));
} finally {
if (oauthModelRequestRef.current === requestId) setOauthModelLoading(false);
}
};
const saveOauthModels = async () => {
if (!oauthModelSettings || oauthModelLoading || oauthModelSaveRef.current) return;
const settings = oauthModelSettings;
oauthModelSaveRef.current = true;
setOauthModelSaving(true);
setOauthModelError('');
try {
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);
}
};
const visibleOauthModels = useMemo(() => {
const query = oauthModelSearch.trim().toLowerCase();
if (!query) return oauthModels;
return oauthModels.filter((model) =>
`${model.id} ${model.displayName ?? ''}`.toLowerCase().includes(query),
);
}, [oauthModelSearch, oauthModels]);
const setOauthModelsExcluded = (models: OAuthModelDefinition[], excluded: boolean) => {
if (oauthModelSaveRef.current) return;
setOauthExcludedRulesText((current) =>
setOAuthModelsExcluded(current.split(/\r?\n/), models, excluded).join('\n'),
);
};
useEffect(() => {
void loadFiles();
}, [loadFiles]);
const providers = useMemo(
() => Array.from(new Set(files.map(providerName))).sort((left, right) => left.localeCompare(right)),
[files],
);
const visibleFiles = useMemo(() => {
const query = filter.trim().toLowerCase();
return files.filter((file) => {
const providerMatch = providerFilter === 'all' || providerName(file) === providerFilter;
const disabled = readBoolean(file, 'disabled');
const runtimeMatch =
statusFilter === 'all' ||
(statusFilter === 'disabled' && disabled) ||
(statusFilter === 'enabled' && !disabled) ||
(statusFilter === 'runtime' && isRuntimeOnly(file));
const searchMatch =
!query ||
[fileName(file), providerName(file), readString(file, 'email', 'account', 'label')]
.join(' ')
.toLowerCase()
.includes(query);
return providerMatch && runtimeMatch && searchMatch;
});
}, [files, filter, providerFilter, statusFilter]);
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
if (selected.length === 0) return;
setBusy(true);
setError('');
let uploaded = 0;
const failures: string[] = [];
for (const file of selected) {
try {
await managementApi.uploadAuthFile(file);
uploaded += 1;
} catch (requestError) {
failures.push(`${file.name}:${String(requestError)}`);
}
}
try {
await loadFiles();
if (uploaded > 0) showNotice({ key: 'authFiles.uploaded', variables: { count: uploaded } });
if (failures.length > 0) setError(t('authFiles.uploadFailed', { count: failures.length, errors: failures.join('; ') }));
} catch (requestError) {
setError(String(requestError));
} finally {
setBusy(false);
}
};
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 loadFiles(false);
} catch (requestError) {
setError(String(requestError));
} finally {
setBusy(false);
}
};
const deleteFile = async (file: AuthFile) => {
const name = fileName(file);
if (isRuntimeOnly(file)) {
setError(t('authFiles.runtimeDeleteError'));
return;
}
if (!await askConfirmation({ title: t('common.delete'), message: t('authFiles.deleteConfirm', { name }), confirmText: t('common.delete'), variant: 'danger' })) return;
setBusy(true);
setError('');
try {
await managementApi.delete('/auth-files', { query: { name } });
showNotice({ key: 'authFiles.deleted' });
await loadFiles();
} catch (requestError) {
setError(String(requestError));
} finally {
setBusy(false);
}
};
const openAuthFilesDirectory = async () => {
setBusy(true);
setError('');
try {
await managementApi.openAuthFilesDirectory();
} catch (requestError) {
setError(String(requestError));
} finally {
setBusy(false);
}
};
const copyName = async (name: string) => {
try {
await navigator.clipboard.writeText(name);
setCopied(name);
window.setTimeout(() => setCopied((current) => (current === name ? '' : current)), 1500);
} catch {
setError(t('common.copyFailed'));
}
};
const disabledCount = files.filter((file) => readBoolean(file, 'disabled')).length;
const runtimeCount = files.filter(isRuntimeOnly).length;
return (
<section className="page management-page auth-files-page">
{confirmationDialog}
<header className="management-header">
<div>
<h1>{t('authFiles.title')}</h1>
</div>
<div className="management-heading-actions">
<span className="muted-summary">{t('authFiles.summary', { files: files.length, disabled: disabledCount })}</span>
<button type="button" className="secondary-button compact-button" onClick={() => {
const provider = oauthModelProviders.find((item) => item.label === providerFilter) ?? oauthModelProviders[0];
if (provider) void openOauthModelSettings({ ...provider, scope: 'provider' });
}} disabled={loading || busy || oauthModelSaving || oauthModelProviders.length === 0}>
<Settings2 size={16} />{t('authFiles.models.globalButton')}
</button>
<button type="button" className="secondary-button compact-button" onClick={() => void loadFiles()} disabled={loading || busy}>
<RefreshCw size={16} />{t('common.refresh')}
</button>
<button type="button" className="secondary-button compact-button" onClick={() => void openAuthFilesDirectory()} disabled={busy}>
<FolderOpen size={16} />{t('authFiles.openDirectory')}
</button>
<button type="button" className="primary-button compact-button" onClick={() => fileInputRef.current?.click()} disabled={busy}>
<Import size={16} />{t('authFiles.import')}
</button>
<input ref={fileInputRef} type="file" accept=".json,application/json" multiple hidden onChange={(event) => void handleUpload(event)} />
</div>
</header>
{error ? <MessageNotice message={error} onDismiss={() => setError('')} /> : null}
<FloatingNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} />
<section className="panel auth-files-panel real-auth-files-panel">
<div className="management-toolbar auth-files-toolbar">
<Search size={16} />
<input value={filter} onChange={(event) => setFilter(event.currentTarget.value)} placeholder={t('authFiles.searchPlaceholder')} />
<select value={providerFilter} onChange={(event) => setProviderFilter(event.currentTarget.value)}>
<option value="all">{t('authFiles.filter.allProviders')}</option>
{providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
</select>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.currentTarget.value as typeof statusFilter)}>
<option value="all">{t('authFiles.filter.allStatuses')}</option>
<option value="enabled">{t('authFiles.filter.enabled')}</option>
<option value="disabled">{t('authFiles.filter.disabled')}</option>
<option value="runtime">{t('authFiles.filter.runtime')}</option>
</select>
</div>
{loading ? (
<div className="management-loading"><LoaderCircle size={20} className="spin" />{t('authFiles.loading')}</div>
) : visibleFiles.length === 0 ? (
<div className="management-empty"><FileDown size={24} /><strong>{files.length ? t('authFiles.empty.filtered') : t('authFiles.empty.none')}</strong><span>{files.length ? t('authFiles.empty.tryFilter') : t('authFiles.empty.upload')}</span></div>
) : (
<div className="auth-file-card-grid">
{visibleFiles.map((file) => {
const name = fileName(file);
const icon = providerIcons[providerKey(file)] ?? geminiIcon;
const disabled = readBoolean(file, 'disabled');
const priority = parseAuthFilePriority(file.priority) ?? 0;
const identity = readString(file, 'email', 'project_id', 'label');
const note = readString(file, 'note');
const quota = quotas[quotaKey(file)] ?? idleQuota();
return (
<article className={`auth-file-card ${disabled ? 'is-disabled' : ''}`} key={`${name}-${readString(file, 'auth_index', 'authIndex')}`}>
<header className={`auth-card-header ${identity ? '' : 'filename-only'}`}>
<img src={icon} alt="" className={providerKey(file) === 'devin' ? 'provider-logo devin-logo' : 'provider-logo'} />
<div className="auth-card-identity"><span className="auth-card-provider">{providerName(file)}</span><strong title={identity || name}>{identity || name}</strong></div>
{isRuntimeOnly(file) ? <span className="state-pill">{t('authFiles.runtime')}</span> : null}
</header>
{identity ? <p className="auth-card-filename">{name}</p> : null}
<AuthFileHealthStatus file={file} receivedAtMs={receivedAtMs} observedAt={observedAt} />
<AuthFileRequestStatus file={file} />
{quotaProviderForFile(file) ? <AuthFileQuotaPanel quota={quota} file={file} disabled={busy || disabled} onRefresh={() => void refreshQuota(file)} onReset={quotaProviderForFile(file) === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} /> : null}
<QuotaActionFeedback quota={quota} name={name} />
<div className="auth-card-meta">
<span>{t('authFiles.priority.button', { priority })} · {readNumber(file, 'size') === null ? t('authFiles.unknownSize') : `${Math.ceil((readNumber(file, 'size') ?? 0) / 1024)} KB`}</span>
<span>{formatDate(file.modtime ?? file.updated_at ?? file.last_refresh)}</span>
</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>
{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="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.delete')}><Trash2 size={15} /></button>
</footer>
</article>
);
})}
</div>
)}
</section>
{runtimeCount > 0 ? <p className="page-footnote">{t('authFiles.runtimeFootnote', { count: runtimeCount })}</p> : null}
{settingsName ? <AuthFileSettingsDialog key={settingsName} name={settingsName} onClose={() => setSettingsName(null)} onSaved={() => {
showNotice({ key: 'authFiles.settings.updated', variables: { name: settingsName } });
setSettingsName(null);
void loadFiles(false);
}} /> : null}
{modelViewName ? <AuthFileModelsDialog name={modelViewName} onClose={() => setModelViewName(null)} /> : null}
{oauthModelTarget ? (
<div className="model-discovery-backdrop" onMouseDown={(event) => event.currentTarget === event.target && !oauthModelSaving && closeOauthModels()}>
<section className="model-discovery-dialog auth-model-dialog" role="dialog" aria-modal="true" aria-labelledby="oauth-model-title" onKeyDown={(event) => { if (event.key === 'Escape') closeOauthModels(); }}>
<div className="model-discovery-header">
<div>
<h2 id="oauth-model-title">{t(oauthModelTarget.scope === 'credential' ? 'authFiles.models.title' : 'authFiles.models.globalButton')}</h2>
{oauthModelTarget.scope === 'credential' ? (
<span className="auth-model-target" title={oauthModelTarget.name}>{oauthModelTarget.name}</span>
) : (
<label className="auth-model-provider">
<span>{t('authFiles.models.provider')}</span>
<select value={oauthModelTarget.provider} disabled={oauthModelSaving} onChange={(event) => {
const provider = oauthModelProviders.find((item) => item.provider === event.currentTarget.value);
if (provider) void openOauthModelSettings({ ...provider, scope: 'provider' });
}}>
{oauthModelProviders.map((provider) => <option key={provider.provider} value={provider.provider}>{provider.label}</option>)}
</select>
</label>
)}
<span>{t(oauthModelTarget.scope === 'credential' ? 'authFiles.models.description' : 'authFiles.models.globalDescription', { provider: oauthModelTarget.label })}</span>
</div>
<button type="button" className="icon-button quiet" onClick={closeOauthModels} disabled={oauthModelSaving} title={t('common.close')}><X size={18} /></button>
</div>
<div className="model-discovery-search">
<Search size={16} aria-hidden="true" />
<input autoFocus value={oauthModelSearch} onChange={(event) => setOauthModelSearch(event.currentTarget.value)} placeholder={t('authFiles.models.search')} />
</div>
<div className="model-discovery-toolbar">
<span>{t('authFiles.models.summary', { total: oauthModels.length, excluded: excludedOauthModelCount })}</span>
<div>
<button type="button" className="secondary-button compact-button" onClick={() => setOauthModelsExcluded(oauthModels, true)} disabled={oauthModelLoading || oauthModelSaving || oauthModels.length === 0} title={t('authFiles.models.excludeAllHint')}>{t('authFiles.models.excludeAll')}</button>
<button type="button" className="secondary-button compact-button" onClick={() => setOauthModelsExcluded(oauthModels, false)} disabled={oauthModelLoading || oauthModelSaving || oauthModels.length === 0} title={t('authFiles.models.clearHint')}>{t('authFiles.models.clearSelected')}</button>
</div>
</div>
<div className="model-discovery-content">
{oauthModelLoading ? (
<div className="model-discovery-message"><LoaderCircle size={20} className="spin" />{t('authFiles.models.loading')}</div>
) : oauthModelError && !oauthModelSettings ? (
<div className="model-discovery-message error"><strong>{t('authFiles.models.loadFailed')}</strong><span>{oauthModelError}</span></div>
) : (
<div className="model-discovery-results">
<div>
{oauthModelError ? <MessageNotice message={oauthModelError} onDismiss={() => setOauthModelError('')} /> : null}
{oauthModelSettings?.catalogError ? <MessageNotice tone="info" message={t('authFiles.models.catalogUnavailable')} /> : null}
</div>
{visibleOauthModels.length === 0 ? (
<div className="model-discovery-message"><strong>{oauthModels.length ? t('authFiles.models.noMatch') : t('authFiles.models.empty')}</strong></div>
) : (
<div className="model-discovery-list">
{visibleOauthModels.map((model) => {
const wildcardRule = oauthExcludedRules.find((rule) => rule.includes('*') && modelMatchesRule(model.id, rule));
const checked = oauthExcludedRules.some((rule) => modelMatchesRule(model.id, rule));
return (
<label className={['model-discovery-row', checked ? 'selected' : '', wildcardRule ? 'rule-blocked' : ''].join(' ')} key={model.id}>
<input type="checkbox" checked={checked} disabled={oauthModelSaving || Boolean(wildcardRule)} onChange={(event) => setOauthModelsExcluded([model], event.currentTarget.checked)} />
<span><strong title={model.id}>{model.id}</strong>{model.displayName ? <small title={model.displayName}>{model.displayName}</small> : null}{wildcardRule ? <small>{t('authFiles.models.wildcardBlocked', { rule: wildcardRule })}</small> : null}</span>
{checked ? <Check size={16} aria-hidden="true" /> : null}
</label>
);
})}
</div>
)}
</div>
)}
</div>
<label className="auth-model-rules" htmlFor="oauth-model-rules">
<span>{t('authFiles.models.rulesLabel')}</span>
<textarea id="oauth-model-rules" rows={3} spellCheck={false} value={oauthExcludedRulesText} disabled={oauthModelLoading || oauthModelSaving || !oauthModelSettings} onChange={(event) => setOauthExcludedRulesText(event.currentTarget.value)} placeholder={t('authFiles.models.rulesPlaceholder')} aria-describedby="oauth-model-rules-hint" />
<small id="oauth-model-rules-hint">{t('authFiles.models.rulesHint')}</small>
</label>
<div className="model-discovery-actions">
<button type="button" className="secondary-button" onClick={closeOauthModels} disabled={oauthModelSaving}>{t('common.cancel')}</button>
<button type="button" className="primary-button" onClick={() => void saveOauthModels()} disabled={oauthModelLoading || oauthModelSaving || !oauthModelSettings}>{oauthModelSaving ? t('common.saving') : t('authFiles.models.save', { count: oauthExcludedRules.length })}</button>
</div>
</section>
</div>
) : null}
</section>
);
}