Files
EasyCLIProxyAPI/src/pages/AuthFileManagementPage.tsx
T
lzt404 0c37f4d1bb feat(providers): add Devin OAuth and quota support
- Add Devin login to standard and simplified modes
- Support credentials, model aliases, and exclusions
- Display daily and weekly quotas with reset times
- Validate callbacks and prevent stale login updates
- Add translations, theme support, and integration tests
2026-09-15 02:39:03 +08:00

702 lines
34 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 { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
import { AuthFileModelsDialog } from '../components/AuthFileModelsDialog';
import {
Check,
Copy,
FileDown,
FolderOpen,
Import,
LoaderCircle,
Pencil,
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 {
formatQuotaTimestamp,
idleQuota,
loadQuota,
providerForFile as quotaProviderForFile,
quotaKey,
type QuotaState,
} from '../services/quotaService';
import {
captureQuotaCacheGeneration,
commitQuotaCacheIfCurrent,
getQuotaCacheSnapshot,
pruneQuotaCache,
updateQuotaCache,
useQuotaCache,
} from '../services/quotaCache';
import {
authFileName,
dedupeAuthFiles,
isRuntimeOnlyAuthFile,
normalizeAuthFilePriorityInput,
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';
import { formatQuotaReset, useQuotaClock } from '../services/quotaTime';
type AuthFile = Record<string, unknown>;
type PriorityEditor = {
fileName: string;
originalPriority: number;
value: string;
error: string;
};
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;
const statusText = (file: AuthFile) => {
if (readBoolean(file, 'disabled')) return translate(getCurrentLocale(), 'authFiles.status.disabled');
if (readBoolean(file, 'unavailable')) return translate(getCurrentLocale(), 'authFiles.status.unavailable');
return readString(file, 'status') || translate(getCurrentLocale(), 'authFiles.status.ready');
};
function AuthFileQuotaSummary({ quota, name }: { quota: QuotaState; name: string }) {
const { locale, t } = useI18n();
const now = useQuotaClock() + (quota.serverTimeOffsetMs ?? 0);
if (quota.status === 'loading') {
return (
<div className="auth-file-quota loading">
<LoaderCircle size={13} className="spin" />
<span>{t('authFiles.quota.loading')}</span>
</div>
);
}
if (quota.status === 'error') {
return (
<div className="auth-file-quota error" title={quota.error}>
<span>{t('authFiles.quota.failed')}</span>
<MessageNotice message={quota.error ? name + ': ' + quota.error : null} />
</div>
);
}
if (quota.status !== 'success') return null;
return (
<div className="auth-file-quota" aria-label={t('authFiles.quota.aria')}>
{quota.plan ? <span className="auth-file-quota-plan">{quota.plan}</span> : null}
{quota.rows.length > 0 ? quota.rows.map((row, index) => {
const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now);
const detail = [row.detail, reset].filter(Boolean).join(' · ');
return (
<span className="auth-file-quota-item" key={`${row.label}-${index}`} title={detail || undefined}>
<span>{row.label}</span>
<strong>{row.remainingPercent === null ? '—' : `${Math.round(row.remainingPercent)}%`}</strong>
{reset ? <small>{reset}</small> : null}
{row.remainingPercent === null && row.detail ? <small>{row.detail}</small> : null}
</span>
);
}) : <span className="auth-file-quota-empty">{t('authFiles.quota.empty')}</span>}
{quota.resetCredits !== undefined ? (
<span className="auth-file-quota-credit">{t('authFiles.quota.resets', { count: quota.resetCredits })}</span>
) : null}
{quota.resetCreditsApplicable !== undefined ? <span className="auth-file-quota-credit">{t('quota.resetApplicable', { count: quota.resetCreditsApplicable })}</span> : null}
{quota.subscriptionActiveUntil ? <span className="auth-file-quota-credit">{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}</span> : null}
<MessageNotice message={quota.resetCreditsError ? name + ': ' + t('quota.resetCreditsWarning', { error: quota.resetCreditsError }) : null} />
{quota.resetCreditsEarliestExpiry ? (
<span className="auth-file-quota-credit">{t('authFiles.quota.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) })}</span>
) : null}
</div>
);
}
export function AuthFileManagementPage() {
const { t } = useI18n();
const { askConfirmation, confirmationDialog } = useConfirmation();
const [files, setFiles] = useState<AuthFile[]>([]);
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 feedback = useAppNotice();
const { showNotice } = feedback;
const [copied, setCopied] = useState('');
const [priorityEditor, setPriorityEditor] = useState<PriorityEditor | 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'));
setFiles(nextFiles);
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 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 (!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 openPriorityEditor = (file: AuthFile) => {
const priority = parseAuthFilePriority(file.priority);
setPriorityEditor({
fileName: fileName(file),
originalPriority: priority ?? 0,
value: priority === undefined || priority === 0 ? '' : String(priority),
error: '',
});
};
const closePriorityEditor = () => {
if (!busy) setPriorityEditor(null);
};
const savePriority = async () => {
if (!priorityEditor || busy) return;
const priority = normalizeAuthFilePriorityInput(priorityEditor.value);
if (priority === null) {
setPriorityEditor((current) => current
? { ...current, error: t('authFiles.priority.invalid') }
: current);
return;
}
if (priority === priorityEditor.originalPriority) {
setPriorityEditor(null);
return;
}
const name = priorityEditor.fileName;
setBusy(true);
setError('');
try {
await managementApi.patch('/auth-files/fields', { name, priority });
setPriorityEditor(null);
showNotice({ key: 'authFiles.priority.updated', variables: { name } });
await loadFiles();
} catch (requestError) {
setPriorityEditor((current) => current
? { ...current, error: String(requestError) }
: current);
} 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="real-auth-file-list">
{visibleFiles.map((file) => {
const name = fileName(file);
const icon = providerIcons[providerKey(file)] ?? geminiIcon;
const disabled = readBoolean(file, 'disabled');
const priority = parseAuthFilePriority(file.priority) ?? 0;
return (
<article className={`real-auth-file-row ${disabled ? 'disabled' : ''}`} key={`${name}-${readString(file, 'auth_index', 'authIndex')}`}>
<img src={icon} alt="" className={providerKey(file) === 'devin' ? 'provider-logo devin-logo' : 'provider-logo'} />
<div className="auth-file-main">
<div className="auth-file-title"><strong title={name}>{name}</strong><span className={`state-pill ${disabled ? 'error' : readBoolean(file, 'unavailable') ? 'error' : 'success'}`} title={readString(file, 'status_message', 'statusMessage') || undefined}>{statusText(file)}</span></div>
<span>{providerName(file)}{readString(file, 'email', 'account', 'label') ? ` · ${readString(file, 'email', 'account', 'label')}` : ''}</span>
</div>
<div className="auth-file-meta">
<span>{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>
{isRuntimeOnly(file) ? <span className="state-pill">{t('authFiles.runtime')}</span> : null}
</div>
<div className="auth-file-actions">
{quotaProviderForFile(file) && !disabled ? <button type="button" className="secondary-button compact-button" onClick={() => void refreshQuota(file)} disabled={busy || quotas[quotaKey(file)]?.status === 'loading'}>{quotas[quotaKey(file)]?.status === 'loading' ? t('authFiles.quota.querying') : quotas[quotaKey(file)]?.status === 'success' ? t('authFiles.quota.refresh') : t('authFiles.quota.fetch')}</button> : null}
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => setModelViewName(name)} disabled={busy || disabled} title={t('authFiles.models.viewTitle')}>{t('authFiles.models.button')}</button> : null}
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void openOauthModels(file)} disabled={busy || disabled || oauthModelSaving || isRuntimeOnly(file) || !readString(file, 'name')} title={t(isRuntimeOnly(file) ? 'authFiles.models.runtimeUnsupported' : 'authFiles.models.settings')}>{t('authFiles.models.excludeButton')}</button> : null}
<button type="button" className="secondary-button compact-button auth-file-priority-button" onClick={() => openPriorityEditor(file)} disabled={busy || disabled} title={t('authFiles.priority.hint')}><Pencil size={14} />{t('authFiles.priority.button', { priority })}</button>
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy || disabled} title={t('authFiles.copyName')}>{copied === name ? <Check size={16} /> : <Copy size={16} />}</button>
<button type="button" className={`${disabled ? 'primary-button' : 'secondary-button'} compact-button`} 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={16} /></button>
</div>
{quotaProviderForFile(file) && quotas[quotaKey(file)]?.status !== 'idle' ? <AuthFileQuotaSummary quota={quotas[quotaKey(file)] ?? idleQuota()} name={name} /> : null}
<QuotaActionFeedback quota={quotas[quotaKey(file)] ?? idleQuota()} name={name} />
</article>
);
})}
</div>
)}
</section>
{runtimeCount > 0 ? <p className="page-footnote">{t('authFiles.runtimeFootnote', { count: runtimeCount })}</p> : null}
{priorityEditor ? (
<div className="config-dialog-backdrop" onMouseDown={(event) => event.currentTarget === event.target && closePriorityEditor()}>
<form
className="config-dialog auth-priority-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="auth-priority-title"
onSubmit={(event) => {
event.preventDefault();
void savePriority();
}}
>
<div className="config-dialog-heading">
<div><Pencil size={19} /><h2 id="auth-priority-title">{t('authFiles.priority.title')}</h2></div>
<button type="button" className="icon-button quiet" onClick={closePriorityEditor} disabled={busy} title={t('common.close')}><X size={18} /></button>
</div>
<div className="auth-priority-target" title={priorityEditor.fileName}>{priorityEditor.fileName}</div>
<label className="auth-priority-field" htmlFor="auth-file-priority-input">
<span>{t('authFiles.priority.label')}</span>
<input
id="auth-file-priority-input"
autoFocus
type="text"
inputMode="numeric"
value={priorityEditor.value}
placeholder={t('authFiles.priority.placeholder')}
disabled={busy}
aria-invalid={Boolean(priorityEditor.error)}
onChange={(event) => {
const value = event.currentTarget.value;
setPriorityEditor((current) => current
? { ...current, value, error: '' }
: current);
}}
/>
<small>{t('authFiles.priority.hint')}</small>
</label>
<div className={`config-form-message ${priorityEditor.error ? 'error' : ''}`} role={priorityEditor.error ? 'alert' : undefined}>
{priorityEditor.error || ' '}
</div>
<div className="config-dialog-actions two-actions">
<button type="button" className="secondary-button" onClick={closePriorityEditor} disabled={busy}>{t('common.cancel')}</button>
<button type="submit" className="primary-button" disabled={busy}>{busy ? <LoaderCircle size={16} className="spin" /> : <Check size={16} />}{busy ? t('common.saving') : t('common.save')}</button>
</div>
</form>
</div>
) : 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>
);
}