Files
Felix FaerberandClaude Opus 4.7 83760c4a9b add shared navbar on /account, /chat, /bee
Three-link nav (Chat / Account / Bee) with active-route highlighting,
plus a t4t Gateway home link on the left. Replaces the bespoke header
on the chat page; account and bee gain the same top-of-page nav.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:29:20 +03:00

406 lines
14 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Nav } from '~/components/nav';
const API_URL = import.meta.env.VITE_API_URL ?? 'https://api.t4t-gateway.com';
const WEI_PER_XBZZ = 10n ** 18n;
type User = { id: string; email: string; role: string; emailVerified: boolean };
type Account = {
id: string;
status: string;
balanceXbzzWei: string;
reservedXbzzWei: string;
availableXbzzWei: string;
balanceUsd: number | null;
availableUsd: number | null;
};
type LedgerEntry = {
id: string;
kind: string;
deltaXbzzWei: string;
createdAt: string;
gatewayJobId: string | null;
topupId: string | null;
note: string | null;
};
type ApiKey = {
id: string;
name: string;
createdAt: string;
lastUsedAt: string | null;
scopeMaxXbzzPerDay: string | null;
modelAllowlist: string[];
};
type NewApiKey = { id: string; name: string; key: string; createdAt: string };
type Job = {
id: string;
status: string;
modelId: string;
provider: string;
estimatedMaxXbzzWei: string;
actualXbzzWei: string | null;
promptTokens: number | null;
completionTokens: number | null;
createdAt: string;
ackedAt: string | null;
deliveredAt: string | null;
claimedAt: string | null;
errorMessage: string | null;
};
export function meta() {
return [{ title: 'Account · t4t Gateway' }];
}
function formatXbzz(weiStr: string): string {
const wei = BigInt(weiStr);
const whole = wei / WEI_PER_XBZZ;
const frac = wei % WEI_PER_XBZZ;
const fracStr = frac.toString().padStart(18, '0').slice(0, 4);
return `${whole.toString()}.${fracStr}`;
}
function formatUsd(cents: number | null): string {
if (cents === null) return '—';
return `$${(cents / 100).toFixed(2)}`;
}
async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = typeof window !== 'undefined' ? localStorage.getItem('jwt') : null;
if (!token) throw new Error('Not authenticated');
const res = await fetch(`${API_URL}${path}`, {
...init,
headers: {
...(init.headers ?? {}),
Authorization: `Bearer ${token}`,
...(init.body ? { 'Content-Type': 'application/json' } : {}),
},
});
if (res.status === 401) {
localStorage.removeItem('jwt');
window.location.href = '/login';
throw new Error('Unauthorized');
}
const body = await res.json();
if (!res.ok) throw new Error(body?.message ?? body?.error ?? `Request failed (${res.status})`);
return body as T;
}
export default function Account() {
const [user, setUser] = useState<User | null>(null);
const [account, setAccount] = useState<Account | null>(null);
const [ledger, setLedger] = useState<LedgerEntry[]>([]);
const [keys, setKeys] = useState<ApiKey[]>([]);
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newKeyName, setNewKeyName] = useState('');
const [creatingKey, setCreatingKey] = useState(false);
const [createdKey, setCreatedKey] = useState<NewApiKey | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') return;
if (!localStorage.getItem('jwt')) {
window.location.href = '/login';
return;
}
(async () => {
try {
const [u, a, l, k, j] = await Promise.all([
api<User>('/users/me'),
api<Account>('/accounts/me'),
api<LedgerEntry[]>('/accounts/me/ledger'),
api<ApiKey[]>('/api-keys'),
api<Job[]>('/v1/jobs?limit=50'),
]);
setUser(u);
setAccount(a);
setLedger(l);
setKeys(k);
setJobs(j);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
})();
}, []);
async function createKey(e: React.FormEvent) {
e.preventDefault();
setCreatingKey(true);
try {
const created = await api<NewApiKey>('/api-keys', {
method: 'POST',
body: JSON.stringify({ name: newKeyName }),
});
setCreatedKey(created);
setNewKeyName('');
const k = await api<ApiKey[]>('/api-keys');
setKeys(k);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setCreatingKey(false);
}
}
async function revokeKey(id: string) {
if (!confirm('Revoke this API key? Existing requests using it will start failing.')) return;
try {
await api(`/api-keys/${id}`, { method: 'DELETE' });
setKeys(keys.filter((k) => k.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
}
function signOut() {
localStorage.removeItem('jwt');
window.location.href = '/';
}
async function copyKey(key: string) {
await navigator.clipboard.writeText(key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
if (loading) {
return (
<>
<Nav />
<main className='flex min-h-[60vh] items-center justify-center'>
<p className='text-sm text-neutral-600'>Loading…</p>
</main>
</>
);
}
return (
<>
<Nav />
<main className='mx-auto max-w-3xl px-6 py-10'>
<div className='flex items-center justify-between'>
<div>
<h1 className='text-2xl font-semibold'>Account</h1>
{user && <p className='text-sm text-neutral-600'>{user.email}</p>}
</div>
<button
onClick={signOut}
className='text-sm font-medium text-neutral-700 underline'
>
Sign out
</button>
</div>
{error && (
<p className='mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700'>
{error}
</p>
)}
{user && !user.emailVerified && (
<p className='mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-900'>
Your email isn't verified yet. The welcome credit lands once you do.
</p>
)}
{account && (
<section className='mt-8 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm'>
<p className='text-xs font-medium uppercase tracking-wide text-neutral-500'>
Balance
</p>
<p className='mt-1 text-4xl font-semibold tracking-tight'>
{formatXbzz(account.availableXbzzWei)}{' '}
<span className='text-xl font-medium text-neutral-500'>xBZZ</span>
</p>
{account.availableUsd !== null && (
<p className='mt-1 text-sm text-neutral-500'>
≈ {formatUsd(account.availableUsd)} USD
</p>
)}
{BigInt(account.reservedXbzzWei) > 0n && (
<p className='mt-2 text-xs text-neutral-500'>
{formatXbzz(account.reservedXbzzWei)} xBZZ reserved on in-flight jobs.
</p>
)}
</section>
)}
<section className='mt-8'>
<h2 className='text-lg font-semibold'>API Keys</h2>
<form
onSubmit={createKey}
className='mt-3 flex gap-2 rounded-lg border border-neutral-200 bg-white p-4'
>
<input
type='text'
placeholder='Key name (e.g. laptop, ci)'
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className='flex-1 rounded-md border border-neutral-300 px-3 py-2 text-sm shadow-sm focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900'
/>
<button
type='submit'
disabled={creatingKey}
className='inline-flex h-10 items-center rounded-md bg-neutral-900 px-4 text-sm font-medium text-white hover:bg-neutral-700 disabled:opacity-50'
>
{creatingKey ? 'Creating…' : 'Create key'}
</button>
</form>
{keys.length === 0 ? (
<p className='mt-4 text-sm text-neutral-500'>No keys yet.</p>
) : (
<ul className='mt-4 divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-white'>
{keys.map((k) => (
<li key={k.id} className='flex items-center justify-between px-4 py-3'>
<div>
<p className='text-sm font-medium'>{k.name || '(unnamed)'}</p>
<p className='text-xs text-neutral-500'>
Created {new Date(k.createdAt).toLocaleDateString()}
{k.lastUsedAt
? ` · last used ${new Date(k.lastUsedAt).toLocaleDateString()}`
: ' · never used'}
</p>
</div>
<button
onClick={() => revokeKey(k.id)}
className='text-sm font-medium text-red-700 hover:underline'
>
Revoke
</button>
</li>
))}
</ul>
)}
</section>
<section className='mt-8'>
<h2 className='text-lg font-semibold'>Recent jobs</h2>
{jobs.length === 0 ? (
<p className='mt-3 text-sm text-neutral-500'>No jobs yet.</p>
) : (
<div className='mt-3 overflow-x-auto rounded-lg border border-neutral-200 bg-white'>
<table className='min-w-full text-sm'>
<thead className='border-b border-neutral-200 bg-neutral-50 text-left text-xs uppercase tracking-wide text-neutral-500'>
<tr>
<th className='px-4 py-2 font-medium'>Model</th>
<th className='px-4 py-2 font-medium'>Status</th>
<th className='px-4 py-2 font-medium'>Provider</th>
<th className='px-4 py-2 font-medium'>Tokens</th>
<th className='px-4 py-2 text-right font-medium'>xBZZ</th>
<th className='px-4 py-2 font-medium'>When</th>
</tr>
</thead>
<tbody className='divide-y divide-neutral-200'>
{jobs.slice(0, 25).map((j) => {
const tokens =
j.promptTokens !== null || j.completionTokens !== null
? `${j.promptTokens ?? '—'} / ${j.completionTokens ?? '—'}`
: '—';
const xbzz = j.actualXbzzWei
? formatXbzz(j.actualXbzzWei)
: `~${formatXbzz(j.estimatedMaxXbzzWei)}`;
const statusColor =
j.status === 'claimed' || j.status === 'delivered'
? 'bg-emerald-100 text-emerald-800'
: j.status === 'failed' || j.status === 'cancelled'
? 'bg-red-100 text-red-800'
: 'bg-neutral-100 text-neutral-700';
return (
<tr key={j.id}>
<td className='px-4 py-2 font-mono text-xs'>{j.modelId}</td>
<td className='px-4 py-2'>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusColor}`}>
{j.status}
</span>
</td>
<td className='px-4 py-2 font-mono text-xs text-neutral-500'>
{j.provider.slice(0, 6)}…{j.provider.slice(-4)}
</td>
<td className='px-4 py-2 font-mono text-xs'>{tokens}</td>
<td className='px-4 py-2 text-right font-mono text-xs'>{xbzz}</td>
<td className='px-4 py-2 text-xs text-neutral-500'>
{new Date(j.createdAt).toLocaleString()}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
<section className='mt-8'>
<h2 className='text-lg font-semibold'>Recent activity</h2>
{ledger.length === 0 ? (
<p className='mt-3 text-sm text-neutral-500'>No ledger entries yet.</p>
) : (
<ul className='mt-3 divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-white'>
{ledger.slice(0, 20).map((entry) => {
const delta = BigInt(entry.deltaXbzzWei);
const positive = delta > 0n;
return (
<li key={entry.id} className='flex items-center justify-between px-4 py-3'>
<div>
<p className='text-sm font-medium'>{entry.kind}</p>
<p className='text-xs text-neutral-500'>
{new Date(entry.createdAt).toLocaleString()}
{entry.note ? ` · ${entry.note}` : ''}
</p>
</div>
<p
className={`font-mono text-sm ${positive ? 'text-emerald-700' : 'text-neutral-900'}`}
>
{positive ? '+' : ''}
{formatXbzz(entry.deltaXbzzWei)}
</p>
</li>
);
})}
</ul>
)}
</section>
{createdKey && (
<div
className='fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-6'
onClick={() => setCreatedKey(null)}
>
<div
className='w-full max-w-md rounded-lg bg-white p-6 shadow-lg'
onClick={(e) => e.stopPropagation()}
>
<h3 className='text-lg font-semibold'>Save your API key</h3>
<p className='mt-1 text-sm text-neutral-600'>
This is the only time you'll see it. Store it somewhere safe.
</p>
<pre className='mt-4 overflow-x-auto rounded-md bg-neutral-100 p-3 font-mono text-xs'>
{createdKey.key}
</pre>
<div className='mt-4 flex gap-2'>
<button
onClick={() => copyKey(createdKey.key)}
className='inline-flex h-9 items-center rounded-md border border-neutral-300 bg-white px-3 text-sm font-medium hover:bg-neutral-100'
>
{copied ? 'Copied!' : 'Copy'}
</button>
<button
onClick={() => setCreatedKey(null)}
className='inline-flex h-9 items-center rounded-md bg-neutral-900 px-4 text-sm font-medium text-white hover:bg-neutral-700'
>
I've saved it
</button>
</div>
</div>
</div>
)}
</main>
</>
);
}