From 97d80c396d8e136e241f9b1bf63c0b97dbe594b8 Mon Sep 17 00:00:00 2001 From: Felix Faerber Date: Wed, 27 May 2026 13:18:05 +0300 Subject: [PATCH] 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) --- app/routes.ts | 1 + app/routes/account.tsx | 323 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 app/routes/account.tsx diff --git a/app/routes.ts b/app/routes.ts index 3c0ba46..26f5acf 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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; diff --git a/app/routes/account.tsx b/app/routes/account.tsx new file mode 100644 index 0000000..3bc2593 --- /dev/null +++ b/app/routes/account.tsx @@ -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(path: string, init: RequestInit = {}): Promise { + 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(null); + const [account, setAccount] = useState(null); + const [ledger, setLedger] = useState([]); + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newKeyName, setNewKeyName] = useState(''); + const [creatingKey, setCreatingKey] = useState(false); + const [createdKey, setCreatedKey] = useState(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('/users/me'), + api('/accounts/me'), + api('/accounts/me/ledger'), + api('/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('/api-keys', { + method: 'POST', + body: JSON.stringify({ name: newKeyName }), + }); + setCreatedKey(created); + setNewKeyName(''); + const k = await api('/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 ( +
+

Loading…

+
+ ); + } + + return ( +
+
+
+

Account

+ {user &&

{user.email}

} +
+ +
+ + {error && ( +

+ {error} +

+ )} + + {user && !user.emailVerified && ( +

+ Your email isn't verified yet. The welcome credit lands once you do. +

+ )} + + {account && ( +
+

+ Balance +

+

+ {formatXbzz(account.availableXbzzWei)}{' '} + xBZZ +

+ {account.availableUsd !== null && ( +

+ ≈ {formatUsd(account.availableUsd)} USD +

+ )} + {BigInt(account.reservedXbzzWei) > 0n && ( +

+ {formatXbzz(account.reservedXbzzWei)} xBZZ reserved on in-flight jobs. +

+ )} +
+ )} + +
+

API Keys

+
+ 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' + /> + +
+ + {keys.length === 0 ? ( +

No keys yet.

+ ) : ( +
    + {keys.map((k) => ( +
  • +
    +

    {k.name || '(unnamed)'}

    +

    + Created {new Date(k.createdAt).toLocaleDateString()} + {k.lastUsedAt + ? ` · last used ${new Date(k.lastUsedAt).toLocaleDateString()}` + : ' · never used'} +

    +
    + +
  • + ))} +
+ )} +
+ +
+

Recent activity

+ {ledger.length === 0 ? ( +

No ledger entries yet.

+ ) : ( +
    + {ledger.slice(0, 20).map((entry) => { + const delta = BigInt(entry.deltaXbzzWei); + const positive = delta > 0n; + return ( +
  • +
    +

    {entry.kind}

    +

    + {new Date(entry.createdAt).toLocaleString()} + {entry.note ? ` · ${entry.note}` : ''} +

    +
    +

    + {positive ? '+' : ''} + {formatXbzz(entry.deltaXbzzWei)} +

    +
  • + ); + })} +
+ )} +
+ + {createdKey && ( +
setCreatedKey(null)} + > +
e.stopPropagation()} + > +

Save your API key

+

+ This is the only time you'll see it. Store it somewhere safe. +

+
+              {createdKey.key}
+            
+
+ + +
+
+
+ )} +
+ ); +}