Files
hosted-gateway-frontend/app/routes/account.tsx
T
Felix FaerberandClaude Opus 4.7 97d80c396d add /account dashboard
- xBZZ balance prominently
- API keys: create / list / revoke; new key surfaced once in a copy dialog
- Recent ledger entries (welcome credit, job charges, top-ups)
- Auth: JWT from localStorage; 401 clears JWT + redirects to /login
- Sign-out button

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:18:05 +03:00

324 lines
11 KiB
TypeScript

import { useEffect, useState } from 'react';
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 };
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 [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] = await Promise.all([
api<User>('/users/me'),
api<Account>('/accounts/me'),
api<LedgerEntry[]>('/accounts/me/ledger'),
api<ApiKey[]>('/api-keys'),
]);
setUser(u);
setAccount(a);
setLedger(l);
setKeys(k);
} 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 (
<main className='flex min-h-screen items-center justify-center'>
<p className='text-sm text-neutral-600'>Loading…</p>
</main>
);
}
return (
<main className='mx-auto min-h-screen max-w-3xl px-6 py-12'>
<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 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>
);
}