feat(frontend): rework ui
This commit is contained in:
parent
10e72b5353
commit
4d9567db9e
13 changed files with 5870 additions and 856 deletions
|
|
@ -1,321 +1,733 @@
|
|||
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, type CodexAccount } from './api';
|
||||
import { fetchCodexQuota, type CodexQuota } from './codexQuota';
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertTitle,
|
||||
Badge,
|
||||
BUILT_IN_THEMES,
|
||||
Button,
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
Input,
|
||||
Progress,
|
||||
ProgressLabel,
|
||||
ProgressValue,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
useTheme,
|
||||
} from '@methanium/ui';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeClosed,
|
||||
EyeOff,
|
||||
EyeOffIcon,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
Moon,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldAlert,
|
||||
Star,
|
||||
Sun,
|
||||
Trash2,
|
||||
UserRound,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const SESSION_KEY = 'vibe-proxy-management-key';
|
||||
import {
|
||||
type AuthFile,
|
||||
cancelLogin,
|
||||
deleteAuthFile,
|
||||
fetchCodexQuota,
|
||||
getLoginStatus,
|
||||
listAuthFiles,
|
||||
startCodexLogin,
|
||||
submitOAuthCallback,
|
||||
} from './api';
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Something went wrong';
|
||||
const managementKeyStorage = 'vibe-proxy-management-key';
|
||||
const quotaCacheStorage = 'vibe-proxy-codex-quota-cache-v1';
|
||||
const methaniumLogo = BUILT_IN_THEMES.find((theme) => theme.id === 'methanium')?.logo;
|
||||
|
||||
type QuotaCache = Record<string, { data: unknown; fetchedAt: number }>;
|
||||
|
||||
function readQuotaCache(): QuotaCache {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(quotaCacheStorage) || '{}') as unknown;
|
||||
return value && typeof value === 'object' ? (value as QuotaCache) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function statusFor(account: CodexAccount): { label: string; tone: string } {
|
||||
if (account.disabled) return { label: 'Disabled', tone: 'muted' };
|
||||
if (account.unavailable || account.status === 'error') return { label: 'Unavailable', tone: 'bad' };
|
||||
return { label: account.status || 'Ready', tone: 'good' };
|
||||
function relativeAge(timestamp: number, now: number) {
|
||||
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
|
||||
if (seconds < 10) return 'just now';
|
||||
if (seconds < 60) return `${seconds} seconds ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} day${days === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
function Login({ onLogin }: { onLogin: (key: string) => void }) {
|
||||
function titleFromKey(key: string) {
|
||||
return key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function quotaWindows(data: unknown) {
|
||||
const windows: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
usedPercent: number;
|
||||
resetAt?: number;
|
||||
resetAfterSeconds?: number;
|
||||
}> = [];
|
||||
const visit = (value: unknown, path: string[]) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return;
|
||||
const record = value as Record<string, unknown>;
|
||||
const used = record.used_percent ?? record.usedPercent;
|
||||
if (typeof used === 'number') {
|
||||
const key = path.join('.') || 'usage';
|
||||
const rawResetAt = record.reset_at ?? record.resetAt;
|
||||
const rawResetAfter = record.reset_after_seconds ?? record.resetAfterSeconds;
|
||||
windows.push({
|
||||
key,
|
||||
label: titleFromKey(path[path.length - 1] || 'Usage'),
|
||||
usedPercent: Math.max(0, Math.min(100, used)),
|
||||
resetAt: typeof rawResetAt === 'number' ? rawResetAt : undefined,
|
||||
resetAfterSeconds: typeof rawResetAfter === 'number' ? rawResetAfter : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (const [key, nested] of Object.entries(record)) {
|
||||
visit(nested, [...path, key]);
|
||||
}
|
||||
};
|
||||
visit(data, []);
|
||||
return windows;
|
||||
}
|
||||
|
||||
function resetLabel(window: ReturnType<typeof quotaWindows>[number]) {
|
||||
if (window.resetAt) {
|
||||
return `Resets ${new Date(window.resetAt * 1000).toLocaleString()}`;
|
||||
}
|
||||
if (window.resetAfterSeconds !== undefined) {
|
||||
const hours = Math.floor(window.resetAfterSeconds / 3600);
|
||||
const minutes = Math.floor((window.resetAfterSeconds % 3600) / 60);
|
||||
return hours > 0 ? `Resets in ${hours}h ${minutes}m` : `Resets in ${minutes}m`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function Header({ loggedIn, onLogout }: { loggedIn: boolean; onLogout: () => void }) {
|
||||
const { resolvedPolarity, setTheme } = useTheme();
|
||||
const isDark = resolvedPolarity === 'dark';
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 h-12 border-b border-sidebar-border bg-sidebar text-sidebar-foreground shadow-sm">
|
||||
<div className="flex h-full items-center justify-between overflow-x-auto px-[10px]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mr-1 shrink-0 px-[13px]! text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
nativeButton={false}
|
||||
render={<a href="/management.html" aria-label="Vibe Proxy management" />}
|
||||
>
|
||||
{methaniumLogo && <img src={methaniumLogo} alt="" className="w-[20.59px]!" />}
|
||||
</Button>
|
||||
<nav className="mr-auto flex h-full shrink-0 items-center" aria-label="Primary">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
nativeButton={false}
|
||||
render={<a href="/management.html" />}
|
||||
>
|
||||
Vibe Proxy
|
||||
</Button>
|
||||
</nav>
|
||||
<div className="flex h-full items-center gap-1">
|
||||
{loggedIn && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
aria-label="Log out"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<LogOut className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||
>
|
||||
{isDark ? <Sun className="size-3.5" /> : <Moon className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Login({ onLogin }: { onLogin: (key: string) => Promise<void> }) {
|
||||
const [key, setKey] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const managementKey = key.trim();
|
||||
if (!managementKey) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.listAccounts(managementKey);
|
||||
sessionStorage.setItem(SESSION_KEY, managementKey);
|
||||
onLogin(managementKey);
|
||||
} catch (loginError) {
|
||||
setError(errorMessage(loginError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-shell">
|
||||
<section className="login-card">
|
||||
<div className="mark" aria-hidden="true">V</div>
|
||||
<p className="eyebrow">Vibe Proxy</p>
|
||||
<h1>Account management</h1>
|
||||
<p className="lede">Sign in with the management key for this server.</p>
|
||||
<form onSubmit={submit}>
|
||||
<label htmlFor="management-key">Management key</label>
|
||||
<input
|
||||
id="management-key"
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(event) => setKey(event.target.value)}
|
||||
placeholder="Enter management key"
|
||||
/>
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
<button className="primary wide" disabled={loading || !key.trim()}>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaView({ quota }: { quota: CodexQuota }) {
|
||||
return (
|
||||
<div className="quota">
|
||||
{quota.planType && <span className="plan">{quota.planType}</span>}
|
||||
{quota.windows.length === 0 ? (
|
||||
<p className="secondary">No quota windows returned.</p>
|
||||
) : (
|
||||
quota.windows.map((window) => (
|
||||
<div className="quota-row" key={window.id}>
|
||||
<div className="quota-label">
|
||||
<span>{window.label}</span>
|
||||
<span>{window.remaining === null ? '--' : `${Math.round(window.remaining)}%`}</span>
|
||||
</div>
|
||||
<div className="meter" aria-label={`${window.label} quota remaining`}>
|
||||
<span style={{ width: `${window.remaining ?? 0}%` }} />
|
||||
</div>
|
||||
{window.resetAt && (
|
||||
<small>Resets {new Date(window.resetAt).toLocaleString()}</small>
|
||||
<main className="flex min-h-[calc(100vh-3rem)] items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Admin access</CardTitle>
|
||||
<CardDescription>
|
||||
Enter the management key configured for this Vibe Proxy instance.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
void onLogin(key)
|
||||
.catch((loginError: unknown) =>
|
||||
setError(loginError instanceof Error ? loginError.message : 'Login failed')
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(event) => setKey(event.target.value)}
|
||||
placeholder="Management key"
|
||||
aria-label="Management key"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<ShieldAlert />
|
||||
<AlertTitle>Access denied</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={!key.trim() || loading}>
|
||||
{loading && <Spinner />}
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountCard({
|
||||
account,
|
||||
managementKey,
|
||||
cachedQuota,
|
||||
now,
|
||||
onQuota,
|
||||
onDelete,
|
||||
}: {
|
||||
account: CodexAccount;
|
||||
account: AuthFile;
|
||||
managementKey: string;
|
||||
onDelete: () => void;
|
||||
cachedQuota?: QuotaCache[string];
|
||||
now: number;
|
||||
onQuota: (authIndex: string, data: unknown) => void;
|
||||
onDelete: (account: AuthFile) => Promise<void>;
|
||||
}) {
|
||||
const [quota, setQuota] = useState<CodexQuota>();
|
||||
const [quotaLoading, setQuotaLoading] = useState(false);
|
||||
const [quotaError, setQuotaError] = useState('');
|
||||
const [loadingQuota, setLoadingQuota] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const status = statusFor(account);
|
||||
const windows = cachedQuota ? quotaWindows(cachedQuota.data) : [];
|
||||
const plan =
|
||||
account.id_token?.plan_type || account.id_token?.chatgpt_plan_type || account.account_type;
|
||||
const identity = account.label || account.email || account.id_token?.email || account.account;
|
||||
const statusProblem = account.disabled || account.unavailable || account.status === 'error';
|
||||
|
||||
async function refreshQuota() {
|
||||
setLoadingQuota(true);
|
||||
const refreshQuota = async () => {
|
||||
setQuotaLoading(true);
|
||||
setQuotaError('');
|
||||
try {
|
||||
setQuota(await fetchCodexQuota(account, managementKey));
|
||||
onQuota(account.auth_index, await fetchCodexQuota(managementKey, account.auth_index));
|
||||
} catch (error) {
|
||||
setQuotaError(errorMessage(error));
|
||||
setQuotaError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setLoadingQuota(false);
|
||||
setQuotaLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function remove() {
|
||||
if (!window.confirm(`Delete ${account.email || 'this Codex account'}?`)) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteAccount(account.name, managementKey);
|
||||
onDelete();
|
||||
} catch (error) {
|
||||
setQuotaError(errorMessage(error));
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
const [emailHidden, setEmailHidden] = useState(true);
|
||||
|
||||
return (
|
||||
<article className="account-card">
|
||||
<div className="account-head">
|
||||
<div className="account-identity">
|
||||
<div className="avatar" aria-hidden="true">{(account.email || 'C')[0].toUpperCase()}</div>
|
||||
<div>
|
||||
<h2>{account.email || 'Email unavailable'}</h2>
|
||||
<span className={`status ${status.tone}`}>{status.label}</span>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex min-w-0 items-center gap-2">
|
||||
<UserRound className="size-4 shrink-0" />
|
||||
{emailHidden ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="truncate">
|
||||
<code>
|
||||
{Array.from({ length: (identity || account.name).length }).map((_, index) => (
|
||||
<span key={index}>*</span>
|
||||
))}
|
||||
</code>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="truncate">{account.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="truncate">
|
||||
<code>{identity || account.name}</code>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="truncate">{account.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setEmailHidden((current) => !current);
|
||||
}}
|
||||
>
|
||||
{emailHidden ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</button>
|
||||
</CardTitle>
|
||||
<CardAction className="flex gap-2">
|
||||
{plan && <Badge variant="outline">{plan[0].toUpperCase() + plan.slice(1)}</Badge>}
|
||||
<Badge variant={statusProblem ? 'destructive' : 'outline'}>
|
||||
{statusProblem ? (
|
||||
account.status_message || <AlertTriangle color="orange" />
|
||||
) : (
|
||||
<Check color="lightGreen" />
|
||||
)}
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-3">
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<div className="text-muted-foreground">Credential refresh</div>
|
||||
<div className="mt-0.5 font-medium">
|
||||
{account.last_refresh
|
||||
? new Date(account.last_refresh).toLocaleDateString()
|
||||
: 'Not reported'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="danger" onClick={remove} disabled={deleting}>
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{account.statusMessage && <p className="account-message">{account.statusMessage}</p>}
|
||||
{quota && <QuotaView quota={quota} />}
|
||||
{quotaError && <p className="form-error" role="alert">{quotaError}</p>}
|
||||
<button className="secondary-button" onClick={refreshQuota} disabled={loadingQuota || account.disabled}>
|
||||
{loadingQuota ? 'Refreshing quota...' : quota ? 'Refresh quota' : 'View quota'}
|
||||
</button>
|
||||
</article>
|
||||
<div className="border-t border-border pt-4 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-medium">Quota</div>
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
title={
|
||||
cachedQuota
|
||||
? `Fetched ${new Date(cachedQuota.fetchedAt).toLocaleString()}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Clock3 className="size-3" />
|
||||
{cachedQuota
|
||||
? `Last fetched ${relativeAge(cachedQuota.fetchedAt, now)}`
|
||||
: 'Not fetched yet'}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refreshQuota()}
|
||||
disabled={quotaLoading}
|
||||
>
|
||||
{quotaLoading ? <Spinner className="animate-spin!" /> : <RefreshCw />}
|
||||
{cachedQuota ? 'Refresh' : 'Fetch quota'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{quotaError && (
|
||||
<Alert variant="destructive" className="mb-3">
|
||||
<ShieldAlert />
|
||||
<AlertTitle>Quota refresh failed</AlertTitle>
|
||||
<AlertDescription>{quotaError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{cachedQuota && windows.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{windows.map((window) => (
|
||||
<div key={window.key}>
|
||||
<Progress value={window.usedPercent}>
|
||||
<ProgressLabel>{window.label}</ProgressLabel>
|
||||
<ProgressValue>{() => `${Math.round(window.usedPercent)}% used`}</ProgressValue>
|
||||
</Progress>
|
||||
{resetLabel(window) && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">{resetLabel(window)}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cachedQuota && windows.length === 0 && (
|
||||
<pre className="max-h-48 overflow-auto bg-muted p-3 text-xs whitespace-pre-wrap text-muted-foreground">
|
||||
{JSON.stringify(cachedQuota.data, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-border pt-4">
|
||||
<Button variant="destructive" size="sm" onClick={() => setDeleteOpen(true)}>
|
||||
<Trash2 />
|
||||
Delete account
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this account?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You will need to authorize it again to restore access.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={() => {
|
||||
setDeleting(true);
|
||||
void onDelete(account).finally(() => {
|
||||
setDeleting(false);
|
||||
setDeleteOpen(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{deleting && <Spinner />}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Management({ managementKey, onLogout }: { managementKey: string; onLogout: () => void }) {
|
||||
const [accounts, setAccounts] = useState<CodexAccount[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
function Dashboard({
|
||||
managementKey,
|
||||
initialAccounts,
|
||||
}: {
|
||||
managementKey: string;
|
||||
initialAccounts: AuthFile[];
|
||||
}) {
|
||||
const [accounts, setAccounts] = useState(initialAccounts);
|
||||
const [quotaCache, setQuotaCache] = useState(readQuotaCache);
|
||||
const [now, setNow] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [oauth, setOauth] = useState<{ url: string; state: string }>();
|
||||
const [login, setLogin] = useState<{ state: string; url: string } | null>(null);
|
||||
const [callbackUrl, setCallbackUrl] = useState('');
|
||||
const [oauthStatus, setOauthStatus] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const pollRef = useRef<number | undefined>(undefined);
|
||||
const [callbackLoading, setCallbackLoading] = useState(false);
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
const reloadAccounts = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
setAccounts(await api.listAccounts(managementKey));
|
||||
} catch (loadError) {
|
||||
setError(errorMessage(loadError));
|
||||
setAccounts(await listAuthFiles(managementKey));
|
||||
} catch (reloadError) {
|
||||
setError(reloadError instanceof Error ? reloadError.message : 'Failed to load accounts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [managementKey]);
|
||||
};
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollRef.current !== undefined) window.clearInterval(pollRef.current);
|
||||
pollRef.current = undefined;
|
||||
useEffect(() => {
|
||||
setNow(Date.now());
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAccounts();
|
||||
return stopPolling;
|
||||
}, [loadAccounts, stopPolling]);
|
||||
if (!login) return;
|
||||
let stopped = false;
|
||||
const poll = window.setInterval(() => {
|
||||
void getLoginStatus(managementKey, login.state)
|
||||
.then((status) => {
|
||||
if (stopped || status.status === 'wait') return;
|
||||
if (status.status === 'error') throw new Error(status.error || 'Authorization failed');
|
||||
window.clearInterval(poll);
|
||||
setLogin(null);
|
||||
return listAuthFiles(managementKey).then(setAccounts);
|
||||
})
|
||||
.catch((pollError: unknown) => {
|
||||
if (stopped) return;
|
||||
window.clearInterval(poll);
|
||||
setError(pollError instanceof Error ? pollError.message : 'Authorization failed');
|
||||
setLogin(null);
|
||||
});
|
||||
}, 1000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
window.clearInterval(poll);
|
||||
};
|
||||
}, [login, managementKey]);
|
||||
|
||||
function pollAuth(state: string) {
|
||||
stopPolling();
|
||||
pollRef.current = window.setInterval(async () => {
|
||||
try {
|
||||
const result = await api.authStatus(state, managementKey);
|
||||
if (result.status === 'ok') {
|
||||
stopPolling();
|
||||
setOauth(undefined);
|
||||
setCallbackUrl('');
|
||||
setOauthStatus('Account added.');
|
||||
setAdding(false);
|
||||
await loadAccounts();
|
||||
} else if (result.status === 'error') {
|
||||
stopPolling();
|
||||
setOauthStatus(result.error || 'Authorization failed.');
|
||||
setAdding(false);
|
||||
}
|
||||
} catch (pollError) {
|
||||
stopPolling();
|
||||
setOauthStatus(errorMessage(pollError));
|
||||
setAdding(false);
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
const storeQuota = (authIndex: string, data: unknown) => {
|
||||
setQuotaCache((current) => {
|
||||
const next = { ...current, [authIndex]: { data, fetchedAt: Date.now() } };
|
||||
localStorage.setItem(quotaCacheStorage, JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
setNow(Date.now());
|
||||
};
|
||||
|
||||
async function addAccount() {
|
||||
setAdding(true);
|
||||
setOauthStatus('');
|
||||
const removeAccount = async (account: AuthFile) => {
|
||||
try {
|
||||
const result = await api.startCodexAuth(managementKey);
|
||||
if (!result.state) throw new Error('The server did not return an OAuth state.');
|
||||
setOauth({ url: result.url, state: result.state });
|
||||
window.open(result.url, '_blank', 'noopener,noreferrer');
|
||||
pollAuth(result.state);
|
||||
} catch (oauthError) {
|
||||
setOauthStatus(errorMessage(oauthError));
|
||||
setAdding(false);
|
||||
await deleteAuthFile(managementKey, account.name);
|
||||
setAccounts((current) => current.filter((item) => item.auth_index !== account.auth_index));
|
||||
setQuotaCache((current) => {
|
||||
const next = { ...current };
|
||||
delete next[account.auth_index];
|
||||
localStorage.setItem(quotaCacheStorage, JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : 'Failed to delete account');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function submitCallback(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!callbackUrl.trim()) return;
|
||||
const beginLogin = async () => {
|
||||
setError('');
|
||||
try {
|
||||
await api.submitCallback(callbackUrl.trim(), managementKey);
|
||||
setOauthStatus('Callback submitted. Waiting for the account...');
|
||||
} catch (callbackError) {
|
||||
setOauthStatus(errorMessage(callbackError));
|
||||
const session = await startCodexLogin(managementKey);
|
||||
setLogin({ state: session.state, url: session.url });
|
||||
window.open(session.url, '_blank', 'noopener,noreferrer');
|
||||
} catch (loginError) {
|
||||
setError(loginError instanceof Error ? loginError.message : 'Failed to start authorization');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stopLogin = async () => {
|
||||
if (!login) return;
|
||||
await cancelLogin(managementKey, login.state).catch(() => undefined);
|
||||
setLogin(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="management-shell">
|
||||
<header className="topbar">
|
||||
<a className="brand" href="/" aria-label="Vibe Proxy home">
|
||||
<span className="mark small" aria-hidden="true">V</span>
|
||||
<span>Vibe Proxy</span>
|
||||
</a>
|
||||
<button className="text-button" onClick={onLogout}>Sign out</button>
|
||||
</header>
|
||||
|
||||
<section className="page-intro">
|
||||
<div>
|
||||
<p className="eyebrow">OpenAI Codex</p>
|
||||
<h1>Accounts</h1>
|
||||
<p className="lede">Connect accounts and check their current usage limits.</p>
|
||||
<main className="mx-auto w-full max-w-6xl p-8 flex flex-col gap-5">
|
||||
{accounts.length === 0 ? null : (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => void reloadAccounts()} disabled={loading}>
|
||||
{loading ? <Spinner /> : <RefreshCw />}
|
||||
Reload accounts
|
||||
</Button>
|
||||
<Button onClick={() => void beginLogin()} disabled={Boolean(login)}>
|
||||
<Plus />
|
||||
Add account
|
||||
</Button>
|
||||
</div>
|
||||
<button className="primary" onClick={addAccount} disabled={adding}>
|
||||
{adding ? 'Waiting for authorization...' : 'Add account'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{oauth && (
|
||||
<section className="oauth-panel">
|
||||
<div>
|
||||
<strong>Finish authorization in the OpenAI window.</strong>
|
||||
<p>If it did not open, <a href={oauth.url} target="_blank" rel="noreferrer">open the authorization link</a>.</p>
|
||||
</div>
|
||||
<form onSubmit={submitCallback}>
|
||||
<label htmlFor="callback-url">Remote server? Paste the full callback URL</label>
|
||||
<div className="inline-form">
|
||||
<input
|
||||
id="callback-url"
|
||||
value={callbackUrl}
|
||||
onChange={(event) => setCallbackUrl(event.target.value)}
|
||||
placeholder="http://localhost:1455/auth/callback?code=..."
|
||||
/>
|
||||
<button className="secondary-button" disabled={!callbackUrl.trim()}>Submit</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{oauthStatus && <p className="notice">{oauthStatus}</p>}
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
|
||||
<section className="account-list" aria-live="polite">
|
||||
{loading ? (
|
||||
<div className="empty">Loading accounts...</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<div className="empty">
|
||||
<h2>No Codex accounts yet</h2>
|
||||
<p>Add an OpenAI account to start routing Codex requests.</p>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 border p-3 rounded border-(--destructive)/40 bg-(--destructive)/25">
|
||||
<ShieldAlert className="mb-4.5 pt-0.5" />
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
<AlertTitle>Management request failed</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</div>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setError('');
|
||||
}}
|
||||
variant="ghost"
|
||||
className="hover:bg-(--destructive)/20!"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{login && (
|
||||
<Card className="flex flex-col gap-0!">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Spinner className="animate-spin!" /> Waiting for authorization
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<a href={login.url} target="_blank" rel="noreferrer" />}
|
||||
>
|
||||
Open sign-in
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
If the browser cannot easily return, paste the entire redirected URL below.
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={callbackUrl}
|
||||
onChange={(event) => setCallbackUrl(event.target.value)}
|
||||
placeholder="http://localhost:1455/auth/callback?code=...&state=..."
|
||||
aria-label="OAuth redirect URL"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!callbackUrl.trim() || callbackLoading}
|
||||
onClick={() => {
|
||||
setCallbackLoading(true);
|
||||
setError('');
|
||||
void submitOAuthCallback(managementKey, login.state, callbackUrl)
|
||||
.then(() => setCallbackUrl(''))
|
||||
.catch((callbackError: unknown) =>
|
||||
setError(
|
||||
callbackError instanceof Error
|
||||
? callbackError.message
|
||||
: 'Failed to submit callback'
|
||||
)
|
||||
)
|
||||
.finally(() => setCallbackLoading(false));
|
||||
}}
|
||||
>
|
||||
{callbackLoading && <Spinner />}
|
||||
Submit callback
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => void stopLogin()}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{accounts.length === 0 ? (
|
||||
<Empty className="min-h-72">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<KeyRound />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No authorized accounts</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Authorize and add your first account to start prompting.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button onClick={() => void beginLogin()}>
|
||||
<Plus />
|
||||
Add account
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{accounts.map((account) => (
|
||||
<AccountCard
|
||||
key={`${account.name}:${String(account.auth_index ?? account.authIndex ?? '')}`}
|
||||
key={account.auth_index}
|
||||
account={account}
|
||||
managementKey={managementKey}
|
||||
onDelete={loadAccounts}
|
||||
cachedQuota={quotaCache[account.auth_index]}
|
||||
now={now}
|
||||
onQuota={storeQuota}
|
||||
onDelete={removeAccount}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [managementKey, setManagementKey] = useState(() => sessionStorage.getItem(SESSION_KEY) || '');
|
||||
export function App() {
|
||||
const [managementKey, setManagementKey] = useState(
|
||||
() => sessionStorage.getItem(managementKeyStorage) || ''
|
||||
);
|
||||
const [accounts, setAccounts] = useState<AuthFile[] | null>(null);
|
||||
|
||||
function logout() {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
useEffect(() => {
|
||||
if (!managementKey || accounts !== null) return;
|
||||
let active = true;
|
||||
void listAuthFiles(managementKey)
|
||||
.then((files) => {
|
||||
if (active) setAccounts(files);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
sessionStorage.removeItem(managementKeyStorage);
|
||||
setManagementKey('');
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [accounts, managementKey]);
|
||||
|
||||
const logout = () => {
|
||||
sessionStorage.removeItem(managementKeyStorage);
|
||||
setManagementKey('');
|
||||
}
|
||||
setAccounts(null);
|
||||
};
|
||||
|
||||
return managementKey ? (
|
||||
<Management managementKey={managementKey} onLogout={logout} />
|
||||
) : (
|
||||
<Login onLogin={setManagementKey} />
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<Header loggedIn={Boolean(managementKey && accounts)} onLogout={logout} />
|
||||
{!managementKey ? (
|
||||
<Login
|
||||
onLogin={async (key) => {
|
||||
const trimmed = key.trim();
|
||||
const files = await listAuthFiles(trimmed);
|
||||
sessionStorage.setItem(managementKeyStorage, trimmed);
|
||||
setManagementKey(trimmed);
|
||||
setAccounts(files);
|
||||
}}
|
||||
/>
|
||||
) : accounts === null ? (
|
||||
<main className="flex min-h-[calc(100vh-3rem)] items-center justify-center">
|
||||
<Spinner className="size-5" />
|
||||
</main>
|
||||
) : (
|
||||
<Dashboard managementKey={managementKey} initialAccounts={accounts} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,95 +1,113 @@
|
|||
const API_ROOT = '/v0/management';
|
||||
const managementBase = '/v0/management';
|
||||
|
||||
export interface CodexAccount {
|
||||
export type AuthFile = {
|
||||
id: string;
|
||||
auth_index: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
provider?: string;
|
||||
label?: string;
|
||||
email?: string;
|
||||
account?: string;
|
||||
account_type?: string;
|
||||
status?: string;
|
||||
status_message?: string;
|
||||
disabled?: boolean;
|
||||
unavailable?: boolean;
|
||||
status?: string;
|
||||
statusMessage?: string;
|
||||
status_message?: string;
|
||||
authIndex?: string | number | null;
|
||||
auth_index?: string | number | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
success?: number;
|
||||
failed?: number;
|
||||
last_refresh?: string;
|
||||
updated_at?: string;
|
||||
id_token?: {
|
||||
plan_type?: string;
|
||||
chatgpt_plan_type?: string;
|
||||
email?: string;
|
||||
};
|
||||
};
|
||||
|
||||
async function request<T>(path: string, managementKey: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_ROOT}${path}`, {
|
||||
async function managementRequest<T>(
|
||||
managementKey: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${managementBase}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${managementKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let body: unknown;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : undefined;
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| ({ error?: string } & T)
|
||||
| null;
|
||||
if (!response.ok) {
|
||||
const detail =
|
||||
body && typeof body === 'object' && 'error' in body
|
||||
? String((body as { error: unknown }).error)
|
||||
: typeof body === 'string'
|
||||
? body
|
||||
: response.statusText;
|
||||
throw new Error(detail || `Request failed with HTTP ${response.status}`);
|
||||
throw new Error(body?.error || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
return body as T;
|
||||
}
|
||||
|
||||
function isCodexAccount(account: CodexAccount): boolean {
|
||||
return [account.type, account.provider].some((value) => String(value || '').toLowerCase() === 'codex');
|
||||
export async function listAuthFiles(managementKey: string) {
|
||||
const response = await managementRequest<{ files: AuthFile[] }>(
|
||||
managementKey,
|
||||
'/auth-files',
|
||||
);
|
||||
return response.files;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async listAccounts(managementKey: string): Promise<CodexAccount[]> {
|
||||
const result = await request<{ files?: CodexAccount[] }>('/auth-files', managementKey);
|
||||
return (result.files || [])
|
||||
.filter(isCodexAccount)
|
||||
.map((account) => ({
|
||||
...account,
|
||||
email: typeof account.email === 'string' ? account.email.trim() : undefined,
|
||||
statusMessage: account.statusMessage || account.status_message,
|
||||
}));
|
||||
},
|
||||
export function deleteAuthFile(managementKey: string, name: string) {
|
||||
return managementRequest<{ status: string }>(
|
||||
managementKey,
|
||||
`/auth-files?name=${encodeURIComponent(name)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
}
|
||||
|
||||
deleteAccount(name: string, managementKey: string) {
|
||||
return request(`/auth-files?name=${encodeURIComponent(name)}`, managementKey, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
export function startCodexLogin(managementKey: string) {
|
||||
return managementRequest<{ status: string; url: string; state: string }>(
|
||||
managementKey,
|
||||
'/codex-auth-url?is_webui=true',
|
||||
);
|
||||
}
|
||||
|
||||
startCodexAuth(managementKey: string) {
|
||||
return request<{ url: string; state?: string }>('/codex-auth-url?is_webui=true', managementKey);
|
||||
},
|
||||
export function getLoginStatus(managementKey: string, state: string) {
|
||||
return managementRequest<{ status: 'ok' | 'wait' | 'error'; error?: string }>(
|
||||
managementKey,
|
||||
`/get-auth-status?state=${encodeURIComponent(state)}`,
|
||||
);
|
||||
}
|
||||
|
||||
authStatus(state: string, managementKey: string) {
|
||||
return request<{ status: 'ok' | 'wait' | 'error'; error?: string }>(
|
||||
`/get-auth-status?state=${encodeURIComponent(state)}`,
|
||||
managementKey,
|
||||
);
|
||||
},
|
||||
export function cancelLogin(managementKey: string, state: string) {
|
||||
return managementRequest<{ status: string; cancelled: boolean }>(
|
||||
managementKey,
|
||||
`/oauth-session?state=${encodeURIComponent(state)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
}
|
||||
|
||||
submitCallback(redirectUrl: string, managementKey: string) {
|
||||
return request('/oauth-callback', managementKey, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider: 'codex', redirect_url: redirectUrl }),
|
||||
});
|
||||
},
|
||||
export function submitOAuthCallback(
|
||||
managementKey: string,
|
||||
state: string,
|
||||
redirectUrl: string,
|
||||
) {
|
||||
return managementRequest<{ status: string }>(managementKey, '/oauth-callback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider: 'codex', state, redirect_url: redirectUrl }),
|
||||
});
|
||||
}
|
||||
|
||||
getCodexQuota(authIndex: string, managementKey: string) {
|
||||
return request<Record<string, unknown>>('/codex-quota', managementKey, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ auth_index: authIndex }),
|
||||
});
|
||||
},
|
||||
};
|
||||
export async function fetchCodexQuota(managementKey: string, authIndex: string) {
|
||||
const response = await managementRequest<{
|
||||
status_code: number;
|
||||
body: string;
|
||||
}>(managementKey, '/codex-quota', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ auth_index: authIndex }),
|
||||
});
|
||||
if (response.status_code < 200 || response.status_code >= 300) {
|
||||
throw new Error(`Quota provider returned status ${response.status_code}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(response.body) as unknown;
|
||||
} catch {
|
||||
throw new Error('Quota provider returned an invalid response');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
import { api, type CodexAccount } from './api';
|
||||
|
||||
interface UsageWindow {
|
||||
used_percent?: unknown;
|
||||
usedPercent?: unknown;
|
||||
reset_at?: unknown;
|
||||
resetAt?: unknown;
|
||||
reset_after_seconds?: unknown;
|
||||
resetAfterSeconds?: unknown;
|
||||
}
|
||||
|
||||
interface RateLimit {
|
||||
primary_window?: UsageWindow | null;
|
||||
primaryWindow?: UsageWindow | null;
|
||||
secondary_window?: UsageWindow | null;
|
||||
secondaryWindow?: UsageWindow | null;
|
||||
}
|
||||
|
||||
function normalizeAuthIndex(value: unknown): string | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value.toString();
|
||||
if (typeof value === 'string') return value.trim() || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeNumberValue(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCodexUsagePayload(payload: unknown): Record<string, unknown> | null {
|
||||
if (typeof payload === 'string' && payload.trim()) {
|
||||
try {
|
||||
return JSON.parse(payload) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return payload !== null && typeof payload === 'object'
|
||||
? (payload as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
export interface CodexQuota {
|
||||
planType: string | null;
|
||||
windows: Array<{ id: string; label: string; remaining: number | null; resetAt: number | null }>;
|
||||
}
|
||||
|
||||
function resetAt(window: UsageWindow): number | null {
|
||||
const absolute = normalizeNumberValue(window.reset_at ?? window.resetAt);
|
||||
if (absolute !== null) return absolute < 1e12 ? absolute * 1000 : absolute;
|
||||
const offset = normalizeNumberValue(window.reset_after_seconds ?? window.resetAfterSeconds);
|
||||
return offset === null ? null : Date.now() + offset * 1000;
|
||||
}
|
||||
|
||||
function addRateLimit(
|
||||
result: CodexQuota['windows'],
|
||||
limit: RateLimit | null | undefined,
|
||||
prefix: string,
|
||||
labels: [string, string],
|
||||
) {
|
||||
const windows = [limit?.primary_window ?? limit?.primaryWindow, limit?.secondary_window ?? limit?.secondaryWindow];
|
||||
windows.forEach((window, index) => {
|
||||
if (!window) return;
|
||||
const used = normalizeNumberValue(window.used_percent ?? window.usedPercent);
|
||||
result.push({
|
||||
id: `${prefix}-${index}`,
|
||||
label: labels[index],
|
||||
remaining: used === null ? null : Math.max(0, Math.min(100, 100 - used)),
|
||||
resetAt: resetAt(window),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCodexQuota(
|
||||
account: CodexAccount,
|
||||
managementKey: string,
|
||||
): Promise<CodexQuota> {
|
||||
const authIndex = normalizeAuthIndex(account.auth_index ?? account.authIndex);
|
||||
if (!authIndex) throw new Error('This account has no auth index, so quota cannot be queried.');
|
||||
|
||||
const response = await api.getCodexQuota(authIndex, managementKey);
|
||||
const statusCode = Number(response.status_code || 0);
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
throw new Error(`Quota request failed with HTTP ${statusCode || 'unknown'}`);
|
||||
}
|
||||
|
||||
const payload = parseCodexUsagePayload(response.body);
|
||||
if (!payload) throw new Error('The quota response was empty or invalid.');
|
||||
|
||||
const source = payload;
|
||||
const windows: CodexQuota['windows'] = [];
|
||||
addRateLimit(
|
||||
windows,
|
||||
(source.rate_limit ?? source.rateLimit) as RateLimit | undefined,
|
||||
'codex',
|
||||
['5-hour limit', 'Weekly limit'],
|
||||
);
|
||||
addRateLimit(
|
||||
windows,
|
||||
(source.code_review_rate_limit ?? source.codeReviewRateLimit) as RateLimit | undefined,
|
||||
'review',
|
||||
['Code review 5-hour limit', 'Code review weekly limit'],
|
||||
);
|
||||
|
||||
return {
|
||||
planType:
|
||||
typeof source.plan_type === 'string'
|
||||
? source.plan_type
|
||||
: typeof source.planType === 'string'
|
||||
? source.planType
|
||||
: typeof account.plan_type === 'string'
|
||||
? account.plan_type
|
||||
: typeof account.planType === 'string'
|
||||
? account.planType
|
||||
: null,
|
||||
windows,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,12 +1,29 @@
|
|||
import './styles.css';
|
||||
|
||||
import { BUILT_IN_THEMES, ThemeProvider } from '@methanium/ui';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles/app.css';
|
||||
|
||||
document.title = 'Vibe Proxy Accounts';
|
||||
import { App } from './App';
|
||||
|
||||
const methaniumTheme = BUILT_IN_THEMES.filter((theme) => theme.id === 'methanium');
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ThemeProvider
|
||||
defaultTheme="dark"
|
||||
themes={methaniumTheme}
|
||||
defaultParentThemeId="methanium"
|
||||
colorStorageKey={null}
|
||||
paletteStorageKey={null}
|
||||
primaryColorStorageKey={null}
|
||||
tintStorageKey={null}
|
||||
borderRadiusStorageKey={null}
|
||||
customCssStorageKey={null}
|
||||
parentThemeStorageKey={null}
|
||||
designStorageKey={null}
|
||||
>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
|
|||
2
frontend/src/styles.css
Normal file
2
frontend/src/styles.css
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
@import "tailwindcss";
|
||||
@import "@methanium/ui/index.css";
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
:root {
|
||||
color: #19221e;
|
||||
background: #f2f5ef;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||
button, input { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.58; }
|
||||
a { color: #166347; }
|
||||
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at 20% 15%, rgba(89, 150, 107, 0.18), transparent 30rem),
|
||||
radial-gradient(circle at 85% 80%, rgba(212, 173, 89, 0.2), transparent 28rem),
|
||||
#f2f5ef;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(100%, 430px);
|
||||
padding: 40px;
|
||||
border: 1px solid #d8ded5;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 24px 80px rgba(39, 61, 49, 0.12);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 28px;
|
||||
border-radius: 14px;
|
||||
color: white;
|
||||
background: #185c43;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.mark.small { width: 32px; height: 32px; margin: 0; border-radius: 10px; font-size: 17px; }
|
||||
.eyebrow { margin: 0 0 8px; color: #527062; font-size: 12px; font-weight: 750; letter-spacing: 0.14em; text-transform: uppercase; }
|
||||
h1 { margin: 0; font-family: Georgia, "Times New Roman", serif; font-size: clamp(38px, 6vw, 58px); font-weight: 500; letter-spacing: -0.035em; }
|
||||
.login-card h1 { font-size: 40px; }
|
||||
.lede { margin: 12px 0 28px; color: #64736b; line-height: 1.55; }
|
||||
label { display: block; margin-bottom: 8px; color: #34473e; font-size: 13px; font-weight: 700; }
|
||||
input { width: 100%; min-height: 46px; padding: 0 14px; border: 1px solid #cbd4cd; border-radius: 10px; color: #19221e; background: white; outline: none; }
|
||||
input:focus { border-color: #287457; box-shadow: 0 0 0 3px rgba(40, 116, 87, 0.12); }
|
||||
|
||||
.primary, .secondary-button, .danger, .text-button {
|
||||
min-height: 42px;
|
||||
padding: 0 17px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
font-weight: 750;
|
||||
}
|
||||
.primary { color: white; background: #185c43; box-shadow: 0 8px 18px rgba(24, 92, 67, 0.18); }
|
||||
.primary:hover { background: #104d37; }
|
||||
.wide { width: 100%; margin-top: 18px; }
|
||||
.secondary-button { color: #245540; border-color: #cbd7d0; background: #f7faf7; }
|
||||
.danger { min-height: 36px; padding: 0 12px; color: #9c3f39; border-color: #ead1ce; background: #fff9f8; }
|
||||
.text-button { color: #52655b; background: transparent; }
|
||||
.form-error { margin: 12px 0 0; color: #a43f37; font-size: 14px; line-height: 1.45; }
|
||||
|
||||
.management-shell { width: min(1120px, calc(100% - 40px)); margin: 0 auto; padding-bottom: 64px; }
|
||||
.topbar { height: 82px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #d9dfd7; }
|
||||
.brand { display: flex; align-items: center; gap: 11px; color: #213b2f; font-weight: 800; text-decoration: none; }
|
||||
.page-intro { display: flex; align-items: end; justify-content: space-between; gap: 28px; padding: 72px 0 38px; }
|
||||
.page-intro .lede { margin-bottom: 0; }
|
||||
.oauth-panel { display: grid; grid-template-columns: 1fr 1.2fr; gap: 28px; margin-bottom: 20px; padding: 22px; border: 1px solid #bed3c6; border-radius: 16px; background: #e9f3ec; }
|
||||
.oauth-panel p { margin: 7px 0 0; color: #53675c; line-height: 1.5; }
|
||||
.inline-form { display: flex; gap: 8px; }
|
||||
.inline-form .secondary-button { flex: 0 0 auto; }
|
||||
.notice { padding: 12px 15px; border-radius: 10px; color: #285942; background: #e6f1e9; }
|
||||
.account-list { display: grid; gap: 14px; }
|
||||
|
||||
.account-card { padding: 24px; border: 1px solid #d6ddd5; border-radius: 18px; background: #fff; box-shadow: 0 8px 30px rgba(39, 61, 49, 0.055); }
|
||||
.account-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
|
||||
.account-identity { min-width: 0; display: flex; align-items: center; gap: 14px; }
|
||||
.avatar { width: 42px; height: 42px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 50%; color: #275c46; background: #dcebe1; font-weight: 800; }
|
||||
.account-card h2 { overflow: hidden; margin: 0 0 5px; font-size: 17px; text-overflow: ellipsis; }
|
||||
.status { display: inline-flex; align-items: center; gap: 5px; color: #557067; font-size: 12px; font-weight: 750; text-transform: capitalize; }
|
||||
.status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.status.good { color: #248052; }
|
||||
.status.bad { color: #b34c43; }
|
||||
.status.muted { color: #7d837f; }
|
||||
.account-message { margin: 14px 0 0; color: #a43f37; font-size: 14px; }
|
||||
.account-card > .secondary-button { margin-top: 18px; }
|
||||
|
||||
.quota { display: grid; gap: 16px; margin-top: 22px; padding: 20px; border-radius: 14px; background: #f4f7f3; }
|
||||
.plan { width: fit-content; padding: 4px 9px; border-radius: 999px; color: #725824; background: #f3e6bd; font-size: 11px; font-weight: 800; text-transform: uppercase; }
|
||||
.quota-row { display: grid; gap: 7px; }
|
||||
.quota-label { display: flex; justify-content: space-between; gap: 20px; font-size: 13px; font-weight: 700; }
|
||||
.meter { height: 7px; overflow: hidden; border-radius: 999px; background: #dbe2dc; }
|
||||
.meter span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #d5a943, #2d855e); transition: width 300ms ease; }
|
||||
.quota small { color: #748078; }
|
||||
.secondary { color: #68756e; }
|
||||
.empty { padding: 64px 24px; border: 1px dashed #c9d2ca; border-radius: 18px; text-align: center; color: #68756e; }
|
||||
.empty h2 { margin: 0 0 8px; color: #263b31; font-family: Georgia, serif; font-size: 25px; font-weight: 500; }
|
||||
.empty p { margin: 0; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.management-shell { width: min(100% - 28px, 1120px); }
|
||||
.topbar { height: 68px; }
|
||||
.page-intro { align-items: stretch; flex-direction: column; padding: 48px 0 28px; }
|
||||
.page-intro .primary { width: 100%; }
|
||||
.oauth-panel { grid-template-columns: 1fr; }
|
||||
.inline-form { flex-direction: column; }
|
||||
.account-card { padding: 19px; }
|
||||
.account-head { align-items: stretch; flex-direction: column; }
|
||||
.danger { align-self: flex-start; }
|
||||
.login-card { padding: 30px 24px; }
|
||||
}
|
||||
2
frontend/src/vite-env.d.ts
vendored
2
frontend/src/vite-env.d.ts
vendored
|
|
@ -1 +1,3 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue