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>
This commit is contained in:
1 parent
0fdfed9909
commit
97d80c396d
2 files changed
+324
No files matched your search
@@ -5,4 +5,5 @@ export default [
|
||||
route('signup', 'routes/signup.tsx'),
|
||||
route('verify', 'routes/verify.tsx'),
|
||||
route('login', 'routes/login.tsx'),
|
||||
route('account', 'routes/account.tsx'),
|
||||
] satisfies RouteConfig;
|
||||
@@ -0,0 +1,323 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user