Compare commits

...
Author SHA1 Message Date
3db5ec25c2
fix(frontend): wrong usage, tooltips, ... 2026-08-30 11:58:15 +02:00
4d9567db9e
feat(frontend): rework ui 2026-08-30 11:34:49 +02:00
14 changed files with 5887 additions and 857 deletions

View file

@ -1,54 +1 @@
# Vibe Proxy
Vibe Proxy connects multiple OpenAI Codex OAuth accounts, displays their quotas, and exposes them through an OpenAI-compatible API.
The management UI is available at `/management.html`. It supports adding and removing Codex accounts and refreshing their quota. Proxy API keys and server settings are deployment configuration, not UI settings.
## Endpoints
- `GET /v1/models`
- `POST /v1/chat/completions`
- `POST /v1/responses`
- `GET /v1/responses` for WebSocket transport
- `GET /healthz`
## NixOS
Add the module and package from the flake:
```nix
{
inputs.vibe-proxy.url = "github:methanium/vibe-proxy";
outputs = { nixpkgs, vibe-proxy, ... }: {
nixosConfigurations.host = nixpkgs.lib.nixosSystem {
modules = [
vibe-proxy.nixosModules.default
({ ... }: {
services.vibe-proxy = {
enable = true;
host = "127.0.0.1";
port = 8317;
settings.api-keys = [ "replace-with-a-random-api-key" ];
environmentFiles = [ "/run/secrets/vibe-proxy" ];
};
})
];
};
};
}
```
The environment file should contain:
```sh
MANAGEMENT_PASSWORD=replace-with-a-random-management-key
```
The module stores OAuth credentials under `/var/lib/vibe-proxy/auths` by default. Set `openFirewall = true` only when the service should be reachable directly from other machines.
Build or run the combined package with `nix build` or `nix run`.
## Other deployments
Copy `backend/config.example.yaml` to `config.yaml`, replace the example API key, and set `MANAGEMENT_PASSWORD`. Docker Compose and the standalone binary both use local JSON credential storage.

View file

@ -317,8 +317,13 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
FileName: fileName,
Storage: tokenStorage,
Metadata: map[string]any{
"email": tokenStorage.Email,
"account_id": tokenStorage.AccountID,
"email": tokenStorage.Email,
"account_id": tokenStorage.AccountID,
"access_token": tokenStorage.AccessToken,
"refresh_token": tokenStorage.RefreshToken,
"id_token": tokenStorage.IDToken,
"expired": tokenStorage.Expire,
"last_refresh": tokenStorage.LastRefresh,
},
}
if errGuard := guardOAuthSessionPendingForSave(state, "codex"); errGuard != nil {

View file

@ -1,10 +1,34 @@
<!doctype html>
<html lang="en">
<html lang="en" class="dark" data-theme="methanium">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f2f5ef" />
<title>Vibe Proxy Accounts</title>
<meta name="theme-color" content="#171e26" />
<title>Vibe Proxy</title>
<style>
html.dark,
html.dark body {
background-color: #171e26;
color: #d2e0f0;
color-scheme: dark;
}
html.light,
html.light body {
background-color: #edf1f4;
color: #10161d;
color-scheme: light;
}
</style>
<script>
const savedTheme = localStorage.getItem('theme');
const theme =
savedTheme === 'light' ||
(savedTheme === 'system' && matchMedia('(prefers-color-scheme: light)').matches)
? 'light'
: 'dark';
document.documentElement.classList.replace('dark', theme);
</script>
</head>
<body>
<div id="root"></div>

View file

@ -1,5 +1,5 @@
{
"name": "cli-proxy-webui-react",
"name": "vibe-proxy-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
@ -15,11 +15,14 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz",
"lucide-react": "^0.542.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@tailwindcss/vite": "^4.1.12",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
@ -28,6 +31,7 @@
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"prettier": "^3.9.5",
"tailwindcss": "^4.1.12",
"typescript": "^6.0.3",
"typescript-eslint": "^8.63.0",
"vite": "^8.1.4",

View file

@ -1,321 +1,744 @@
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,
TooltipProvider,
TooltipTrigger,
useTheme,
} from '@methanium/ui';
import {
AlertTriangle,
Check,
Clock3,
ExternalLink,
Eye,
EyeOff,
KeyRound,
LogOut,
Moon,
Plus,
RefreshCw,
ShieldAlert,
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 quotaWindowLabel(record: Record<string, unknown>, key: string) {
const rawSeconds =
record.limit_window_seconds ?? record.limitWindowSeconds ?? record.window_seconds;
if (typeof rawSeconds === 'number') {
if (rawSeconds === 5 * 60 * 60) return '5-hour limit';
if (rawSeconds === 7 * 24 * 60 * 60) return 'Weekly limit';
const days = rawSeconds / 86400;
if (Number.isInteger(days)) return `${days}-day limit`;
const hours = rawSeconds / 3600;
if (Number.isInteger(hours)) return `${hours}-hour limit`;
}
return titleFromKey(key);
}
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: quotaWindowLabel(record, 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 ? (
<code className="truncate">
{Array.from({ length: (identity || account.name).length }).map((_, index) => (
<span key={index}>*</span>
))}
</code>
) : (
<Tooltip>
<TooltipTrigger
render={<code className="truncate">{identity || account.name}</code>}
/>
<TooltipContent>{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={100 - window.usedPercent} max={100}>
<ProgressLabel>{window.label}</ProgressLabel>
<ProgressValue>
{() => `${Math.round(100 - 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 (
<TooltipProvider>
<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>
</TooltipProvider>
);
}

View file

@ -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');
}
}

View file

@ -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,
};
}

View file

@ -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
View file

@ -0,0 +1,2 @@
@import "tailwindcss";
@import "@methanium/ui/index.css";

View file

@ -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; }
}

View file

@ -1 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;

View file

@ -1,3 +1,5 @@
import { methaniumUi } from '@methanium/ui/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
@ -39,7 +41,7 @@ function getVersion(): string {
// https://vitejs.dev/config/
export default defineConfig(({ command }) => ({
base: command === 'build' ? '/management-assets/' : '/',
plugins: [react()],
plugins: [tailwindcss(), react(), methaniumUi({ defaultThemeId: 'methanium' })],
define: {
__APP_VERSION__: JSON.stringify(getVersion())
},

View file

@ -34,7 +34,7 @@ let
pnpmDeps = fetchPnpmDeps {
inherit (finalAttrs) pname version src;
fetcherVersion = 4;
hash = "sha256-vWBgw08KLHoih2DE/o9e8G4JQMjJBnFEwsCaiag8FNc=";
hash = "sha256-/2dyjSVU4MgWSz70d9T8xUzOxkykl98Flc9AmCKAf9E=";
};
nativeBuildInputs = [

4918
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff