Files
EasyCLIProxyAPI/src/pages/ManagementPages.tsx
T

664 lines
23 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
Check,
Copy,
ExternalLink,
LoaderCircle,
LogIn,
RefreshCw,
} 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 grokIcon from '../assets/icons/grok.svg';
import devinIcon from '../assets/icons/devin.svg';
import kimiIcon from '../assets/icons/kimi-light.svg';
import { useI18n } from '../i18n';
import { FloatingNotice, useAppNotice } from '../appNotice';
import { oauthSubpages, type OAuthSubpage } from '../oauthNavigation';
import {
changedOAuthAuthFileNames,
snapshotAuthFiles,
type AuthFileSnapshot,
} from '../services/authFiles';
import { managementApi, responseList } from '../services/managementApi';
import {
createOAuthLoginSuccessCache,
shouldShowOAuthLoginStatus,
} from '../services/oauthLoginState';
import { AuthFileManagementPage } from './AuthFileManagementPage';
import { QuotaPage } from './QuotaPage';
import { validateDevinCallback } from '../services/devinOAuth';
import { handleHorizontalTabKey } from '../components/tabKeyboardNavigation';
type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai' | 'devin';
type OAuthFlowStatus = 'idle' | 'waiting' | 'success' | 'error';
type OAuthProviderState = {
url?: string;
state?: string;
status: OAuthFlowStatus;
error?: string;
polling?: boolean;
callbackUrl?: string;
callbackSubmitting?: boolean;
callbackStatus?: 'success' | 'error';
callbackError?: string;
refreshing?: boolean;
};
type OAuthStartResult = {
url: string;
state?: string | null;
opened: boolean;
openError?: string | null;
};
type OAuthBrowserOption = {
id: string;
label: string;
};
type OAuthStatusResult = {
status: string;
error?: string | null;
};
const oauthProviders = [
{ id: 'codex' as const, name: 'Codex OAuth', icon: codexIcon },
{ id: 'claude' as const, name: 'Claude OAuth', icon: claudeIcon },
{ id: 'antigravity' as const, name: 'Antigravity OAuth', icon: antigravityIcon },
{ id: 'kimi' as const, name: 'Kimi OAuth', icon: kimiIcon },
{ id: 'xai' as const, name: 'xAI OAuth', icon: grokIcon },
{ id: 'devin' as const, name: 'Devin OAuth', icon: devinIcon },
];
const OAUTH_CALLBACK_SUPPORTED = new Set<OAuthProviderId>([
'codex',
'claude',
'antigravity',
'xai',
'devin',
]);
const XAI_CALLBACK_URL = 'http://127.0.0.1:56121/callback';
const OAUTH_POLL_INTERVAL_MS = 3000;
const OAUTH_BROWSER_STORAGE_KEY = 'easy-cli-proxy-api.oauth-browser.v3';
const NO_AUTO_OPEN_BROWSER_ID = 'none';
const oauthLoginSuccessCache = createOAuthLoginSuccessCache<OAuthProviderId>();
const resolveOAuthBrowserSelection = (
available: OAuthBrowserOption[],
preferred: string,
): string => {
if (preferred === NO_AUTO_OPEN_BROWSER_ID) return preferred;
if (available.some((browser) => browser.id === preferred)) return preferred;
return available.find((browser) => browser.id !== 'default')?.id
?? available.find((browser) => browser.id === 'default')?.id
?? NO_AUTO_OPEN_BROWSER_ID;
};
const loadOAuthBrowserPreference = (): string => {
try {
return window.localStorage.getItem(OAUTH_BROWSER_STORAGE_KEY)?.trim() || '';
} catch {
return '';
}
};
const cachedOAuthProviderStates = (): Partial<Record<OAuthProviderId, OAuthProviderState>> => (
oauthLoginSuccessCache.snapshot().reduce<Partial<Record<OAuthProviderId, OAuthProviderState>>>(
(states, provider) => ({ ...states, [provider]: { status: 'success' } }),
{},
)
);
export function OAuthManagementPage() {
const { t } = useI18n();
const [activeSubpage, setActiveSubpage] = useState<OAuthSubpage>('login');
const subpageIds = oauthSubpages.map((subpage) => subpage.id);
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, subpage: OAuthSubpage) => {
handleHorizontalTabKey(
event,
subpageIds,
subpage,
setActiveSubpage,
(next) => document.getElementById(`oauth-subpage-tab-${next}`),
);
};
return (
<section className="page oauth-management-page">
<div
className="agent-subpage-tabs oauth-subpage-tabs"
role="tablist"
aria-label={t('oauth.tabs.label')}
>
{oauthSubpages.map((subpage) => {
const active = activeSubpage === subpage.id;
return (
<button
type="button"
id={`oauth-subpage-tab-${subpage.id}`}
key={subpage.id}
role="tab"
className={active ? 'active' : ''}
aria-selected={active}
aria-controls="oauth-subpage-panel"
tabIndex={active ? 0 : -1}
onClick={() => setActiveSubpage(subpage.id)}
onKeyDown={(event) => handleTabKeyDown(event, subpage.id)}
>
{t(subpage.labelKey)}
</button>
);
})}
</div>
<div
className="oauth-subpage-panel"
id="oauth-subpage-panel"
role="tabpanel"
aria-labelledby={`oauth-subpage-tab-${activeSubpage}`}
>
{activeSubpage === 'login' ? <OAuthLoginPage /> : null}
{activeSubpage === 'authFiles' ? <AuthFileManagementPage /> : null}
{activeSubpage === 'quota' ? <QuotaPage /> : null}
</div>
</section>
);
}
export function OAuthLoginPage() {
const { t } = useI18n();
const [states, setStates] = useState<Partial<Record<OAuthProviderId, OAuthProviderState>>>(
cachedOAuthProviderStates,
);
const feedback = useAppNotice();
const { showNotice } = feedback;
const [browsers, setBrowsers] = useState<OAuthBrowserOption[]>([]);
const [browsersLoading, setBrowsersLoading] = useState(true);
const [selectedBrowser, setSelectedBrowser] = useState(loadOAuthBrowserPreference);
const pollingTimers = useRef<Partial<Record<OAuthProviderId, number>>>({});
const pollingRequests = useRef<Partial<Record<OAuthProviderId, boolean>>>({});
const pollingSessions = useRef<Partial<Record<OAuthProviderId, string>>>({});
const credentialSnapshots = useRef<Partial<Record<OAuthProviderId, AuthFileSnapshot>>>({});
useEffect(() => {
let active = true;
void invoke<OAuthBrowserOption[]>('list_oauth_browsers')
.then((available) => {
if (!active) return;
setBrowsers(available);
setSelectedBrowser((current) => resolveOAuthBrowserSelection(available, current));
})
.catch((error) => {
if (!active) return;
console.warn('Failed to detect installed browsers', error);
const fallback = [{ id: 'default', label: 'System Default' }];
setBrowsers(fallback);
setSelectedBrowser((current) => resolveOAuthBrowserSelection(fallback, current));
})
.finally(() => {
if (active) setBrowsersLoading(false);
});
return () => { active = false; };
}, []);
useEffect(() => {
try {
window.localStorage.setItem(OAUTH_BROWSER_STORAGE_KEY, selectedBrowser);
} catch {
}
}, [selectedBrowser]);
const updateProviderState = useCallback(
(provider: OAuthProviderId, next: Partial<OAuthProviderState>) => {
setStates((current) => ({
...current,
[provider]: {
status: 'idle',
...(current[provider] ?? {}),
...next,
},
}));
},
[],
);
const clearPollingTimer = useCallback((provider: OAuthProviderId) => {
const timer = pollingTimers.current[provider];
if (timer !== undefined) window.clearInterval(timer);
delete pollingSessions.current[provider];
delete pollingTimers.current[provider];
delete pollingRequests.current[provider];
}, []);
const completeProviderAuth = useCallback(
(provider: OAuthProviderId) => {
clearPollingTimer(provider);
oauthLoginSuccessCache.mark(provider);
updateProviderState(provider, {
url: undefined,
state: undefined,
status: 'success',
error: undefined,
polling: false,
callbackUrl: '',
callbackSubmitting: false,
callbackStatus: undefined,
callbackError: undefined,
});
},
[clearPollingTimer, updateProviderState],
);
const captureCredentialSnapshot = useCallback(async (provider: OAuthProviderId) => {
const payload = await managementApi.get('/auth-files');
credentialSnapshots.current[provider] = snapshotAuthFiles(responseList(payload, 'files'));
}, []);
const applyDefaultCredentialPriority = useCallback(async (provider: OAuthProviderId) => {
const before = credentialSnapshots.current[provider];
delete credentialSnapshots.current[provider];
if (!before) return;
const payload = await managementApi.get('/auth-files');
const names = changedOAuthAuthFileNames(before, responseList(payload, 'files'), provider);
await Promise.all(names.map((name) => managementApi.patch('/auth-files/fields', {
name,
priority: 0,
})));
}, []);
const startPolling = useCallback(
(provider: OAuthProviderId, state: string) => {
clearPollingTimer(provider);
pollingSessions.current[provider] = state;
const isCurrent = () => pollingSessions.current[provider] === state;
const checkStatus = async () => {
if (pollingRequests.current[provider]) return;
pollingRequests.current[provider] = true;
try {
const result = await invoke<OAuthStatusResult>('get_oauth_status', { state });
if (!isCurrent()) return;
const status = (result.status || '').toLowerCase();
if (status === 'ok') {
let priorityError = '';
try {
await applyDefaultCredentialPriority(provider);
} catch (error) {
priorityError = String(error);
}
if (!isCurrent()) return;
completeProviderAuth(provider);
showNotice(
priorityError
? t('oauth.priorityApplyFailed', { error: priorityError })
: t('oauth.loginSuccess', { provider: providerLabel(provider) }),
priorityError ? 'error' : 'success',
);
} else if (status === 'error') {
updateProviderState(provider, {
status: 'error',
error: result.error || t('oauth.authFailed'),
polling: false,
});
clearPollingTimer(provider);
showNotice(
{ key: 'oauth.loginFailed', variables: {
provider: providerLabel(provider),
detail: result.error ? `: ${result.error}` : '',
} },
'error',
);
}
} catch (error) {
if (!isCurrent()) return;
updateProviderState(provider, {
status: 'error',
error: String(error),
polling: false,
});
clearPollingTimer(provider);
showNotice(String(error), 'error');
} finally {
if (isCurrent()) delete pollingRequests.current[provider];
}
};
pollingTimers.current[provider] = window.setInterval(
() => void checkStatus(),
OAUTH_POLL_INTERVAL_MS,
);
},
[applyDefaultCredentialPriority, clearPollingTimer, completeProviderAuth, showNotice, t, updateProviderState],
);
useEffect(() => {
return () => {
pollingSessions.current = {};
Object.values(pollingTimers.current).forEach((timer) => {
if (timer !== undefined) window.clearInterval(timer);
});
};
}, []);
const startLogin = async (provider: OAuthProviderId) => {
clearPollingTimer(provider);
updateProviderState(provider, {
url: undefined,
state: undefined,
status: 'waiting',
polling: true,
error: undefined,
callbackUrl: '',
callbackStatus: undefined,
callbackError: undefined,
});
try {
await captureCredentialSnapshot(provider);
const result = await invoke<OAuthStartResult>('start_oauth_login', {
provider,
browser: selectedBrowser,
});
if (!result.state) {
updateProviderState(provider, {
url: result.url,
state: undefined,
status: 'error',
error: t('oauth.missingState'),
polling: false,
});
showNotice({ key: 'oauth.missingStatePolling' }, 'error');
return;
}
updateProviderState(provider, {
url: result.url,
state: result.state,
status: 'waiting',
polling: true,
});
startPolling(provider, result.state);
if (!result.opened) {
showNotice(
result.openError
? t('oauth.openFailedDetail', { error: result.openError })
: t('oauth.openFailed'),
'info',
);
}
} catch (error) {
delete credentialSnapshots.current[provider];
updateProviderState(provider, {
status: 'error',
error: String(error),
polling: false,
});
showNotice(String(error), 'error');
}
};
const refreshLoginLink = async (provider: OAuthProviderId) => {
const currentState = states[provider]?.state;
clearPollingTimer(provider);
updateProviderState(provider, { refreshing: true });
try {
if (currentState) {
await managementApi.delete('/oauth-session', { query: { state: currentState } });
}
} catch (error) {
if (provider === 'devin') {
updateProviderState(provider, { refreshing: false });
if (currentState) startPolling(provider, currentState);
showNotice(String(error), 'error');
return;
}
console.warn('Failed to cancel the previous OAuth session before refreshing', error);
}
try {
await startLogin(provider);
} finally {
updateProviderState(provider, { refreshing: false });
}
};
const openAuthUrl = async (url?: string) => {
if (!url) return;
try {
await invoke('open_oauth_url', {
url,
browser: selectedBrowser === NO_AUTO_OPEN_BROWSER_ID ? 'default' : selectedBrowser,
});
} catch (error) {
showNotice(String(error), 'error');
}
};
const copyAuthUrl = async (url?: string) => {
if (!url) return;
try {
await navigator.clipboard.writeText(url);
showNotice({ key: 'oauth.linkCopied' }, 'success');
} catch {
showNotice({ key: 'oauth.linkCopyFailed' }, 'error');
}
};
const submitCallback = async (provider: OAuthProviderId) => {
const current = states[provider];
const callbackInput = (current?.callbackUrl || '').trim();
if (!callbackInput) {
showNotice(
provider === 'xai' ? t('oauth.pasteXaiCallback') : t('oauth.pasteCallback'),
'error',
);
return;
}
if (provider === 'devin') {
const error = validateDevinCallback(callbackInput, current?.state);
if (error) {
showNotice(t(error === 'state_mismatch' ? 'oauth.devinStateMismatch' : 'oauth.invalidCallback'), 'error');
return;
}
}
const redirectUrl = resolveCallbackUrl(provider, callbackInput, current?.state);
if (!redirectUrl) {
showNotice(
provider === 'xai'
? t('oauth.invalidXaiCallback')
: t('oauth.invalidCallback'),
'error',
);
return;
}
updateProviderState(provider, {
callbackSubmitting: true,
callbackStatus: undefined,
callbackError: undefined,
});
try {
await invoke('submit_oauth_callback', { provider, redirectUrl });
updateProviderState(provider, {
callbackSubmitting: false,
callbackStatus: 'success',
});
showNotice({ key: 'oauth.callbackSubmittedNotice' }, 'success');
} catch (error) {
updateProviderState(provider, {
callbackSubmitting: false,
callbackStatus: 'error',
callbackError: String(error),
});
showNotice(String(error), 'error');
}
};
return (
<section className="page management-page">
<header className="management-header">
<div><h1>{t('oauth.title')}</h1></div>
<label className="oauth-browser-picker">
<span>{t('oauth.browser.label')}</span>
<select
value={selectedBrowser}
onChange={(event) => setSelectedBrowser(event.currentTarget.value)}
aria-label={t('oauth.browser.label')}
disabled={browsersLoading}
>
{browsersLoading ? <option value="">{t('oauth.browser.detecting')}</option> : null}
{browsers.map((browser) => (
<option value={browser.id} key={browser.id}>
{browser.id === 'default' ? t('oauth.browser.systemDefault') : browser.label}
</option>
))}
<option value={NO_AUTO_OPEN_BROWSER_ID}>{t('oauth.browser.noAutoOpen')}</option>
</select>
<small>{t('oauth.browser.remembered')}</small>
</label>
</header>
<FloatingNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} />
<div className="oauth-grid">
{oauthProviders.map((provider) => {
const state = states[provider.id] ?? { status: 'idle' as const };
const canSubmitCallback = OAUTH_CALLBACK_SUPPORTED.has(provider.id) && Boolean(state.url);
const loginLabel = state.status === 'success'
? t('oauth.loginAnother')
: state.polling
? t('oauth.loggingIn')
: t('oauth.startLogin');
return (
<section className="panel oauth-card" key={provider.id}>
<div className="provider-title-row">
<img src={provider.icon} alt="" className={provider.id === 'devin' ? 'provider-logo devin-logo' : 'provider-logo'} />
<div>
<h2>{provider.name}</h2>
{shouldShowOAuthLoginStatus(state.status) ? (
<span className="state-pill success">{t('oauth.status.completed')}</span>
) : null}
</div>
</div>
<div className="oauth-card-body">
<p className="oauth-hint">{t(provider.id === 'devin' ? 'oauth.devinHint' : 'oauth.hint')}</p>
{state.url ? (
<div className="oauth-auth-url-box">
<div className="oauth-auth-url-label">{t('oauth.authorizationLink')}</div>
<div className="oauth-auth-url-value" title={state.url}>{state.url}</div>
<div className="oauth-auth-url-actions">
<button type="button" className="secondary-button compact-button" onClick={() => void copyAuthUrl(state.url)}>
<Copy size={15} aria-hidden="true" />{t('oauth.copyLink')}
</button>
<button type="button" className="secondary-button compact-button" onClick={() => void openAuthUrl(state.url)}>
<ExternalLink size={15} aria-hidden="true" />{t('oauth.openLink')}
</button>
</div>
</div>
) : null}
{canSubmitCallback ? (
<div className="oauth-callback-block">
<div className="oauth-callback-row">
<input
value={state.callbackUrl ?? ''}
onChange={(event) => {
const value = event.currentTarget.value;
updateProviderState(provider.id, {
callbackUrl: value,
callbackStatus: undefined,
callbackError: undefined,
});
}}
placeholder={provider.id === 'xai' ? t('oauth.xaiCallbackPlaceholder') : t('oauth.callbackPlaceholder')}
/>
<button type="button" className="secondary-button" disabled={state.callbackSubmitting} onClick={() => void submitCallback(provider.id)}>
{state.callbackSubmitting ? <LoaderCircle size={16} className="spin" aria-hidden="true" /> : <Check size={16} aria-hidden="true" />}
{t('oauth.submitCallback')}
</button>
</div>
</div>
) : null}
</div>
<div className="button-row management-card-actions">
<button type="button" className="primary-button" disabled={Boolean(state.polling) || browsersLoading} onClick={() => void startLogin(provider.id)}>
{state.polling ? <LoaderCircle size={16} className="spin" aria-hidden="true" /> : <LogIn size={16} aria-hidden="true" />}
{loginLabel}
</button>
{state.url ? (
<button type="button" className="secondary-button" disabled={browsersLoading || state.refreshing} onClick={() => void refreshLoginLink(provider.id)}>
<RefreshCw size={16} className={state.refreshing ? 'spin' : undefined} aria-hidden="true" />
{state.refreshing ? t('oauth.refreshingLink') : t('oauth.refreshLink')}
</button>
) : null}
</div>
</section>
);
})}
</div>
</section>
);
}
function providerLabel(provider: OAuthProviderId) {
return oauthProviders.find((item) => item.id === provider)?.name ?? provider;
}
function isAbsoluteUrl(value: string) {
try {
new URL(value);
return true;
} catch {
return false;
}
}
function readQueryLikeCallbackInput(value: string) {
const trimmed = value.trim();
if (!trimmed) return null;
const queryStart = trimmed.indexOf('?');
const hashStart = trimmed.indexOf('#');
const rawParams = queryStart >= 0
? trimmed.slice(queryStart + 1)
: hashStart >= 0
? trimmed.slice(hashStart + 1)
: trimmed;
if (!/(^|[&#?])(code|state|error)=/i.test(rawParams)) return null;
return new URLSearchParams(rawParams.replace(/^[?#]/, ''));
}
function buildXaiCallbackUrl(input: string, state?: string) {
const trimmed = input.trim();
if (!trimmed) return null;
if (isAbsoluteUrl(trimmed)) return trimmed;
const params = readQueryLikeCallbackInput(trimmed);
if (params) {
const callbackState = params.get('state')?.trim() || state?.trim();
if (!callbackState) return null;
const callbackUrl = new URL(XAI_CALLBACK_URL);
callbackUrl.searchParams.set('state', callbackState);
for (const key of ['code', 'error', 'error_description']) {
const value = params.get(key)?.trim();
if (value) callbackUrl.searchParams.set(key, value);
}
return callbackUrl.toString();
}
const code = (trimmed.match(/\bcode\s*[:=]\s*([^\s&]+)/i)?.[1] ?? trimmed).trim();
const callbackState = state?.trim();
if (!code || !callbackState) return null;
const callbackUrl = new URL(XAI_CALLBACK_URL);
callbackUrl.searchParams.set('code', code);
callbackUrl.searchParams.set('state', callbackState);
return callbackUrl.toString();
}
function resolveCallbackUrl(provider: OAuthProviderId, input: string, state?: string) {
return provider === 'xai' ? buildXaiCallbackUrl(input, state) : input.trim();
}