feat(ui): embedded MPC admin dashboard — @hanzo/gui + go:embed

Vite + React SPA with 9 pages (Dashboard, Wallets, Vaults, Policies,
Settlements, Bridge, Payments, Audit, Settings) served at /_/mpc/ via
go:embed. Replaces the separate Next.js dashboard pod — the UI now ships
inside the mpcd binary. Uses @tanstack/react-query, wouter hash router,
dark theme, inline styles. Build output: 272KB.
This commit is contained in:
2025-12-27 21:28:17 -08:00
parent 856ddb2381
commit 8ea4e30598
22 changed files with 1532 additions and 33 deletions
+6 -1
View File
@@ -32,11 +32,16 @@ logs/
# Docker build artifacts
.docker-build/
# Dashboard build
# Dashboard build (legacy Next.js)
dashboard/node_modules/
dashboard/.next/
dashboard/package-lock.json
# Embedded UI build
ui/node_modules/
ui/dist/
ui/pnpm-lock.yaml
# Environment
.env
.env.local
+6
View File
@@ -29,9 +29,12 @@ import (
"github.com/urfave/cli/v3"
"github.com/hanzoai/base"
"github.com/hanzoai/base/apis"
"github.com/hanzoai/base/core"
"github.com/luxfi/hsm"
uimpc "github.com/luxfi/mpc/ui"
mpcapi "github.com/luxfi/mpc/pkg/api"
"github.com/luxfi/mpc/pkg/backup"
"github.com/luxfi/mpc/pkg/db"
@@ -712,6 +715,9 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
os.Args = []string{"mpcd", "serve", "--http", apiListenAddr}
baseApp := base.New()
baseApp.OnServe().BindFunc(func(e *core.ServeEvent) error {
// Embedded admin UI at /_/mpc/
e.Router.GET("/_/mpc/{path...}", apis.Static(uimpc.DistDirFS(), true))
e.Router.Any("/{path...}", func(re *core.RequestEvent) error {
apiServer.Handler().ServeHTTP(re.Response, re.Request)
return nil
-32
View File
@@ -1,32 +0,0 @@
FROM node:20-alpine AS deps
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM node:20-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_API_URL=https://mpc.lux.network/api/v1
ARG NEXT_PUBLIC_IAM_URL=https://lux.id
ARG NEXT_PUBLIC_IAM_CLIENT_ID=lux-mpc
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_IAM_URL=$NEXT_PUBLIC_IAM_URL
ENV NEXT_PUBLIC_IAM_CLIENT_ID=$NEXT_PUBLIC_IAM_CLIENT_ID
RUN pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
+24
View File
@@ -0,0 +1,24 @@
// Package ui embeds the MPC admin dashboard built with Vite + React.
//
// The Go HTTP handler serves the admin UI at the /_/mpc/ path.
// Run `pnpm --dir ui build` in this directory to produce dist/; the
// //go:embed directive below picks up the resulting static assets.
package ui
import (
"embed"
"io/fs"
)
//go:embed all:dist
var distFS embed.FS
// DistDirFS returns a rooted fs.FS pointing at dist/ so it can be passed
// directly to the Base router's Static handler.
func DistDirFS() fs.FS {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
panic("mpc-ui: dist/ missing — run `pnpm --dir ui build` first: " + err.Error())
}
return sub
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MPC Admin</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root { height: 100%; width: 100%; background: #000; color: #e5e5e5; font-family: system-ui, -apple-system, sans-serif; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@luxfi/mpc-ui",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"wouter": "^3.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
+89
View File
@@ -0,0 +1,89 @@
import { Router, Route, Switch, Link, useLocation } from 'wouter'
import { useHashLocation } from 'wouter/use-hash-location'
import { Dashboard } from './pages/Dashboard'
import { Wallets } from './pages/Wallets'
import { Vaults } from './pages/Vaults'
import { Policies } from './pages/Policies'
import { Settlements } from './pages/Settlements'
import { Bridge } from './pages/Bridge'
import { Payments } from './pages/Payments'
import { Audit } from './pages/Audit'
import { Settings } from './pages/Settings'
const navItems = [
{ href: '/', label: 'Dashboard' },
{ href: '/wallets', label: 'Wallets' },
{ href: '/vaults', label: 'Vaults' },
{ href: '/policies', label: 'Policies' },
{ href: '/settlements', label: 'Settlements' },
{ href: '/bridge', label: 'Bridge' },
{ href: '/payments', label: 'Payments' },
{ href: '/audit', label: 'Audit' },
{ href: '/settings', label: 'Settings' },
] as const
function NavLink({ href, label }: { href: string; label: string }) {
const [location] = useLocation()
const active = href === '/' ? location === '/' : location.startsWith(href)
return (
<Link
href={href}
style={{
display: 'block',
padding: '6px 12px',
borderRadius: 6,
color: active ? '#fff' : '#a3a3a3',
background: active ? '#262626' : 'transparent',
textDecoration: 'none',
fontSize: 14,
}}
>
{label}
</Link>
)
}
export function App() {
return (
<Router hook={useHashLocation}>
<div style={{ display: 'flex', height: '100vh', background: '#000', color: '#e5e5e5' }}>
<aside
style={{
width: 200,
borderRight: '1px solid #262626',
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 2,
flexShrink: 0,
}}
>
<div style={{ fontWeight: 600, fontSize: 16, marginBottom: 16, color: '#fff' }}>
MPC Admin
</div>
<nav style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{navItems.map((n) => (
<NavLink key={n.href} href={n.href} label={n.label} />
))}
</nav>
</aside>
<main style={{ flex: 1, overflow: 'auto', padding: 24 }}>
<Switch>
<Route path="/" component={Dashboard} />
<Route path="/wallets" component={Wallets} />
<Route path="/vaults" component={Vaults} />
<Route path="/policies" component={Policies} />
<Route path="/settlements" component={Settlements} />
<Route path="/bridge" component={Bridge} />
<Route path="/payments" component={Payments} />
<Route path="/audit" component={Audit} />
<Route path="/settings" component={Settings} />
<Route>
<div style={{ color: '#737373' }}>Not found</div>
</Route>
</Switch>
</main>
</div>
</Router>
)
}
+45
View File
@@ -0,0 +1,45 @@
const colors: Record<string, { bg: string; fg: string }> = {
running: { bg: '#22c55e20', fg: '#22c55e' },
active: { bg: '#22c55e20', fg: '#22c55e' },
enabled: { bg: '#22c55e20', fg: '#22c55e' },
finalized: { bg: '#22c55e20', fg: '#22c55e' },
verified: { bg: '#22c55e20', fg: '#22c55e' },
signed: { bg: '#22c55e20', fg: '#22c55e' },
paid: { bg: '#22c55e20', fg: '#22c55e' },
pending: { bg: '#f59e0b20', fg: '#f59e0b' },
pending_approval: { bg: '#f59e0b20', fg: '#f59e0b' },
pending_sign: { bg: '#f59e0b20', fg: '#f59e0b' },
hsm_signing: { bg: '#f59e0b20', fg: '#f59e0b' },
confirming: { bg: '#f59e0b20', fg: '#f59e0b' },
broadcast: { bg: '#3b82f620', fg: '#3b82f6' },
settling: { bg: '#3b82f620', fg: '#3b82f6' },
signing: { bg: '#3b82f620', fg: '#3b82f6' },
degraded: { bg: '#f9731620', fg: '#f97316' },
resharing: { bg: '#f9731620', fg: '#f97316' },
failed: { bg: '#ef444420', fg: '#ef4444' },
rejected: { bg: '#ef444420', fg: '#ef4444' },
disabled: { bg: '#52525220', fg: '#737373' },
archived: { bg: '#52525220', fg: '#737373' },
expired: { bg: '#52525220', fg: '#737373' },
cancelled: { bg: '#52525220', fg: '#737373' },
}
const fallback = { bg: '#26262620', fg: '#a3a3a3' }
export function StatusBadge({ status }: { status: string }) {
const c = colors[status] ?? fallback
return (
<span
style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: 4,
fontSize: 12,
background: c.bg,
color: c.fg,
}}
>
{status.replace(/_/g, ' ')}
</span>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '../lib/api'
export function useStatus() {
return useQuery({ queryKey: ['mpc', 'status'], queryFn: api.getStatus })
}
export function useInfo() {
return useQuery({ queryKey: ['mpc', 'info'], queryFn: api.getInfo })
}
export function useVaults() {
return useQuery({ queryKey: ['mpc', 'vaults'], queryFn: api.listVaults })
}
export function useWallets(vaultId: string) {
return useQuery({
queryKey: ['mpc', 'wallets', vaultId],
queryFn: () => api.listWallets(vaultId),
enabled: !!vaultId,
})
}
export function useTransactions(filters?: { status?: string; chain?: string }) {
return useQuery({
queryKey: ['mpc', 'transactions', filters],
queryFn: () => api.listTransactions(filters),
})
}
export function usePolicies() {
return useQuery({ queryKey: ['mpc', 'policies'], queryFn: api.listPolicies })
}
export function useSettlements(filters?: { status?: string }) {
return useQuery({
queryKey: ['mpc', 'settlements', filters],
queryFn: () => api.listSettlements(filters),
})
}
export function useBridgeConfig() {
return useQuery({ queryKey: ['mpc', 'bridge', 'config'], queryFn: api.getBridgeConfig })
}
export function useBridgeNetworks() {
return useQuery({ queryKey: ['mpc', 'bridge', 'networks'], queryFn: api.listBridgeNetworks })
}
export function usePaymentRequests() {
return useQuery({ queryKey: ['mpc', 'payments'], queryFn: api.listPaymentRequests })
}
export function useAudit() {
return useQuery({ queryKey: ['mpc', 'audit'], queryFn: api.listAudit })
}
export function useWalletBackups(walletId: string) {
return useQuery({
queryKey: ['mpc', 'backup', walletId],
queryFn: () => api.getWalletBackups(walletId),
enabled: !!walletId,
})
}
+233
View File
@@ -0,0 +1,233 @@
// Typed API client for MPC backend — all paths relative to /v1/mpc
const BASE = '/v1/mpc'
export class APIError extends Error {
constructor(public status: number, public body: { error: string }) {
super(body.error)
this.name = 'APIError'
}
}
function token(): string | null {
return localStorage.getItem('mpc_token')
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const t = token()
if (t) headers['Authorization'] = `Bearer ${t}`
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (res.status === 204) return undefined as T
const data = await res.json()
if (!res.ok) throw new APIError(res.status, data)
return data as T
}
function get<T>(path: string): Promise<T> { return request<T>('GET', path) }
function post<T>(path: string, body?: unknown): Promise<T> { return request<T>('POST', path, body) }
function patch<T>(path: string, body?: unknown): Promise<T> { return request<T>('PATCH', path, body) }
function del<T>(path: string): Promise<T> { return request<T>('DELETE', path) }
// --- Types (matching Go backend models) ---
export interface ClusterStatus {
node_id: string
mode: string
expected_peers: number
connected_peers: number
ready: boolean
threshold: number
version: string
}
export interface InfoResponse {
name: string
version: string
supported_chains: string[]
key_types: string[]
protocols: string[]
}
export interface Vault {
id: string
org_id: string
name: string
description?: string | null
created_at: string
}
export interface Wallet {
id: string
vault_id: string
org_id: string
wallet_id: string
name?: string | null
key_type: string
protocol?: string
ecdsa_pubkey?: string | null
eddsa_pubkey?: string | null
eth_address?: string | null
btc_address?: string | null
sol_address?: string | null
threshold: number
participants: string[]
version: number
status: string
created_at: string
}
export interface Transaction {
id: string
org_id: string
wallet_id?: string | null
tx_type: string
chain: string
to_address?: string | null
amount?: string | null
token?: string | null
tx_hash?: string | null
status: string
created_at: string
}
export interface Policy {
id: string
org_id: string
vault_id?: string | null
name: string
priority: number
action: string
conditions: Record<string, unknown>
required_approvers: number
approver_roles: string[]
enabled: boolean
created_at: string
}
export interface Settlement {
id: string
org_id: string
intent_id: string
match_id?: string | null
settlement_tx_hash?: string | null
hsm_signatures?: { signer_id: string; key_id: string; signature: string; provider: string; signed_at: string }[]
transfer_agency_verified: boolean
status: string
created_at: string
}
export interface PaymentRequest {
id: string
org_id: string
merchant_name?: string | null
recipient_address: string
chain: string
token?: string | null
amount: string
status: string
created_at: string
}
export interface AuditEntry {
id: number
org_id: string
user_id?: string | null
action: string
resource_type?: string | null
resource_id?: string | null
ip_address?: string | null
created_at: string
}
export interface WalletBackup {
id: string
wallet_id: string
backup_id: string
threshold: number
total_shards: number
shards?: { index: number; destination: string; created_at: string; verified_at?: string | null }[]
status: string
created_at: string
}
export interface BridgeNetwork {
chain: string
name: string
type: string
deposit: boolean
withdrawal: boolean
}
export interface BridgeConfig {
signingWalletId?: string
feeRateBps: number
depositsEnabled: boolean
withdrawalsEnabled: boolean
}
// --- API methods ---
export const api = {
// Status
getStatus: () => get<ClusterStatus>('/status'),
getInfo: () => get<InfoResponse>('/info'),
// Vaults
listVaults: () => get<Vault[]>('/vaults'),
createVault: (req: { name: string; description?: string }) => post<Vault>('/vaults', req),
deleteVault: (id: string) => del(`/vaults/${id}`),
// Wallets
listWallets: (vaultId: string) => get<Wallet[]>(`/vaults/${vaultId}/wallets`),
createWallet: (vaultId: string, req?: { name?: string; key_type?: string; protocol?: string }) =>
post<Wallet>(`/vaults/${vaultId}/wallets`, req),
getWalletBackups: (walletId: string) => get<WalletBackup[]>(`/wallets/${walletId}/backup`),
// Transactions
listTransactions: (filters?: { status?: string; chain?: string }) => {
const params = new URLSearchParams()
if (filters?.status) params.set('status', filters.status)
if (filters?.chain) params.set('chain', filters.chain)
const qs = params.toString()
return get<Transaction[]>(`/transactions${qs ? `?${qs}` : ''}`)
},
// Policies
listPolicies: () => get<Policy[]>('/policies'),
createPolicy: (req: {
name: string
action: string
priority: number
conditions: Record<string, unknown>
required_approvers: number
approver_roles: string[]
}) => post<Policy>('/policies', req),
updatePolicy: (id: string, req: { enabled?: boolean }) => patch<Policy>(`/policies/${id}`, req),
deletePolicy: (id: string) => del(`/policies/${id}`),
// Settlements
listSettlements: (filters?: { status?: string }) => {
const params = new URLSearchParams()
if (filters?.status) params.set('status', filters.status)
const qs = params.toString()
return get<Settlement[]>(`/settlements${qs ? `?${qs}` : ''}`)
},
// Bridge
getBridgeConfig: () => get<BridgeConfig>('/bridge/config'),
listBridgeNetworks: () => get<BridgeNetwork[]>('/bridge/networks'),
// Payments
listPaymentRequests: () => get<PaymentRequest[]>('/payment-requests'),
// Audit
listAudit: () => get<AuditEntry[]>('/audit'),
}
+24
View File
@@ -0,0 +1,24 @@
import { createRoot } from 'react-dom/client'
import React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App } from './App'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
})
const root = document.getElementById('root')
if (!root) throw new Error('root element missing')
createRoot(root).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
+65
View File
@@ -0,0 +1,65 @@
import { useAudit } from '../hooks/use-mpc'
export function Audit() {
const { data: entries, error } = useAudit()
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Audit Log</h1>
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
Chronological signing event log.
</p>
</div>
{error && <div style={{ color: '#ef4444', fontSize: 13 }}>{(error as Error).message}</div>}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
<th style={{ padding: '8px 12px' }}>Action</th>
<th style={{ padding: '8px 12px' }}>Resource</th>
<th style={{ padding: '8px 12px' }}>User</th>
<th style={{ padding: '8px 12px' }}>IP</th>
<th style={{ padding: '8px 12px' }}>Date</th>
</tr>
</thead>
<tbody>
{entries?.map((entry) => (
<tr key={entry.id} style={{ borderBottom: '1px solid #171717' }}>
<td style={{ padding: '8px 12px', fontWeight: 500 }}>{entry.action}</td>
<td style={{ padding: '8px 12px', color: '#a3a3a3' }}>
{entry.resource_type ? (
<>
{entry.resource_type}
{entry.resource_id && (
<span style={{ fontFamily: 'monospace', fontSize: 11, marginLeft: 4 }}>
{entry.resource_id.slice(0, 12)}...
</span>
)}
</>
) : '--'}
</td>
<td style={{ padding: '8px 12px', fontFamily: 'monospace', fontSize: 11, color: '#737373' }}>
{entry.user_id || '--'}
</td>
<td style={{ padding: '8px 12px', fontFamily: 'monospace', fontSize: 11, color: '#737373' }}>
{entry.ip_address || '--'}
</td>
<td style={{ padding: '8px 12px', color: '#737373' }}>
{new Date(entry.created_at).toLocaleString()}
</td>
</tr>
))}
{(!entries || entries.length === 0) && (
<tr>
<td colSpan={5} style={{ padding: '16px 12px', color: '#525252' }}>
No audit entries
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { useBridgeConfig, useBridgeNetworks } from '../hooks/use-mpc'
const card: React.CSSProperties = {
background: '#0a0a0a',
border: '1px solid #262626',
borderRadius: 8,
padding: 16,
}
const label: React.CSSProperties = {
fontSize: 12,
color: '#737373',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}
export function Bridge() {
const { data: config } = useBridgeConfig()
const { data: networks } = useBridgeNetworks()
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Bridge</h1>
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
Cross-chain bridge status and transfers.
</p>
</div>
{/* Config cards */}
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<div style={card}>
<div style={label}>Signing Wallet</div>
<div style={{ marginTop: 4, fontFamily: 'monospace', fontSize: 13 }}>
{config?.signingWalletId ? config.signingWalletId.slice(0, 12) + '...' : 'Not configured'}
</div>
</div>
<div style={card}>
<div style={label}>Fee Rate</div>
<div style={{ marginTop: 4, fontSize: 18, fontWeight: 600, color: '#fff' }}>
{config ? (config.feeRateBps / 100).toFixed(2) : '--'}%
</div>
</div>
<div style={card}>
<div style={label}>Deposits</div>
<div style={{ marginTop: 4, color: config?.depositsEnabled ? '#22c55e' : '#ef4444' }}>
{config?.depositsEnabled ? 'Enabled' : 'Disabled'}
</div>
</div>
<div style={card}>
<div style={label}>Withdrawals</div>
<div style={{ marginTop: 4, color: config?.withdrawalsEnabled ? '#22c55e' : '#ef4444' }}>
{config?.withdrawalsEnabled ? 'Enabled' : 'Disabled'}
</div>
</div>
</div>
{/* Networks */}
<div>
<div style={label}>Supported Networks</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 8 }}>
{networks?.map((net) => (
<div
key={net.chain}
style={{
...card,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 14, fontWeight: 500 }}>{net.name}</span>
<span style={{
background: '#262626',
borderRadius: 4,
padding: '2px 8px',
fontSize: 11,
fontFamily: 'monospace',
color: '#737373',
}}>
{net.type}
</span>
</div>
<div style={{ display: 'flex', gap: 16, fontSize: 12 }}>
<span style={{ color: net.deposit ? '#22c55e' : '#525252' }}>
Deposit: {net.deposit ? 'ON' : 'OFF'}
</span>
<span style={{ color: net.withdrawal ? '#22c55e' : '#525252' }}>
Withdrawal: {net.withdrawal ? 'ON' : 'OFF'}
</span>
</div>
</div>
))}
{(!networks || networks.length === 0) && (
<div style={{ color: '#525252', fontSize: 13 }}>No networks configured</div>
)}
</div>
</div>
</div>
)
}
+126
View File
@@ -0,0 +1,126 @@
import { useStatus, useVaults, useTransactions } from '../hooks/use-mpc'
import { StatusBadge } from '../components/StatusBadge'
const card: React.CSSProperties = {
background: '#0a0a0a',
border: '1px solid #262626',
borderRadius: 8,
padding: 16,
minWidth: 140,
}
const label: React.CSSProperties = {
fontSize: 12,
color: '#737373',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}
const value: React.CSSProperties = {
fontSize: 28,
fontWeight: 600,
color: '#fff',
marginTop: 4,
fontFamily: 'monospace',
}
export function Dashboard() {
const { data: cluster } = useStatus()
const { data: vaults } = useVaults()
const { data: txList } = useTransactions()
const pending = txList?.filter((t) => t.status === 'pending' || t.status === 'pending_approval').length ?? 0
const recent = txList?.slice(0, 8) ?? []
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Dashboard</h1>
{cluster && (
<StatusBadge status={cluster.ready ? 'running' : 'degraded'} />
)}
</div>
{/* Stat cards */}
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<div style={card}>
<div style={label}>Vaults</div>
<div style={value}>{vaults?.length ?? '--'}</div>
</div>
<div style={card}>
<div style={label}>Pending TX</div>
<div style={value}>{pending}</div>
</div>
<div style={card}>
<div style={label}>Peers</div>
<div style={value}>
{cluster ? `${cluster.connected_peers}/${cluster.expected_peers}` : '--'}
</div>
</div>
<div style={card}>
<div style={label}>Threshold</div>
<div style={value}>
{cluster ? `${cluster.threshold}-of-${cluster.expected_peers}` : '--'}
</div>
</div>
<div style={card}>
<div style={label}>Version</div>
<div style={{ ...value, fontSize: 16 }}>
{cluster?.version ?? '--'}
</div>
</div>
</div>
{/* Cluster info */}
{cluster && (
<div style={card}>
<div style={label}>Cluster Node</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: cluster.ready ? '#22c55e' : '#f59e0b',
}} />
<span style={{ fontFamily: 'monospace', fontSize: 14, color: '#fff' }}>{cluster.node_id}</span>
<span style={{ fontSize: 12, color: '#737373', marginLeft: 'auto' }}>{cluster.mode}</span>
</div>
</div>
)}
{/* Recent transactions */}
<div>
<div style={label}>Recent Transactions</div>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 8, fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
<th style={{ padding: '8px 12px' }}>Type</th>
<th style={{ padding: '8px 12px' }}>Chain</th>
<th style={{ padding: '8px 12px' }}>Amount</th>
<th style={{ padding: '8px 12px' }}>Status</th>
<th style={{ padding: '8px 12px' }}>Date</th>
</tr>
</thead>
<tbody>
{recent.map((tx) => (
<tr key={tx.id} style={{ borderBottom: '1px solid #171717' }}>
<td style={{ padding: '8px 12px', textTransform: 'capitalize' }}>{tx.tx_type}</td>
<td style={{ padding: '8px 12px' }}>{tx.chain}</td>
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>{tx.amount ?? '--'}</td>
<td style={{ padding: '8px 12px' }}><StatusBadge status={tx.status} /></td>
<td style={{ padding: '8px 12px', color: '#737373' }}>
{new Date(tx.created_at).toLocaleDateString()}
</td>
</tr>
))}
{recent.length === 0 && (
<tr>
<td colSpan={5} style={{ padding: '16px 12px', color: '#525252' }}>
No recent transactions
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
+57
View File
@@ -0,0 +1,57 @@
import { usePaymentRequests } from '../hooks/use-mpc'
import { StatusBadge } from '../components/StatusBadge'
export function Payments() {
const { data: payments, error } = usePaymentRequests()
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Payments</h1>
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
Deposit/withdrawal lifecycle with MPC co-sign status.
</p>
</div>
{error && <div style={{ color: '#ef4444', fontSize: 13 }}>{(error as Error).message}</div>}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
<th style={{ padding: '8px 12px' }}>Merchant</th>
<th style={{ padding: '8px 12px' }}>Amount</th>
<th style={{ padding: '8px 12px' }}>Chain</th>
<th style={{ padding: '8px 12px' }}>Recipient</th>
<th style={{ padding: '8px 12px' }}>Status</th>
<th style={{ padding: '8px 12px' }}>Created</th>
</tr>
</thead>
<tbody>
{payments?.map((pr) => (
<tr key={pr.id} style={{ borderBottom: '1px solid #171717' }}>
<td style={{ padding: '8px 12px', fontWeight: 500 }}>{pr.merchant_name || '--'}</td>
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>
{pr.amount} {pr.token || ''}
</td>
<td style={{ padding: '8px 12px', textTransform: 'capitalize' }}>{pr.chain}</td>
<td style={{ padding: '8px 12px', fontFamily: 'monospace', fontSize: 11 }}>
{pr.recipient_address.slice(0, 10)}...{pr.recipient_address.slice(-6)}
</td>
<td style={{ padding: '8px 12px' }}><StatusBadge status={pr.status} /></td>
<td style={{ padding: '8px 12px', color: '#737373' }}>
{new Date(pr.created_at).toLocaleDateString()}
</td>
</tr>
))}
{(!payments || payments.length === 0) && (
<tr>
<td colSpan={6} style={{ padding: '16px 12px', color: '#525252' }}>
No payment requests
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
+163
View File
@@ -0,0 +1,163 @@
import { useState, type FormEvent } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { usePolicies } from '../hooks/use-mpc'
import { api } from '../lib/api'
import { StatusBadge } from '../components/StatusBadge'
const card: React.CSSProperties = {
background: '#0a0a0a',
border: '1px solid #262626',
borderRadius: 8,
padding: 16,
}
const input: React.CSSProperties = {
background: '#0a0a0a',
border: '1px solid #262626',
borderRadius: 6,
padding: '6px 12px',
color: '#e5e5e5',
fontSize: 13,
width: '100%',
}
const actionOptions = ['approve', 'deny', 'require_approval']
export function Policies() {
const queryClient = useQueryClient()
const { data: policies } = usePolicies()
const [name, setName] = useState('')
const [action, setAction] = useState('require_approval')
const [priority, setPriority] = useState(0)
const [requiredApprovers, setRequiredApprovers] = useState(1)
const [approverRoles, setApproverRoles] = useState('admin,owner')
const [creating, setCreating] = useState(false)
const [err, setErr] = useState('')
async function handleCreate(e: FormEvent) {
e.preventDefault()
if (!name.trim()) return
setCreating(true)
setErr('')
try {
await api.createPolicy({
name,
action,
priority,
conditions: {},
required_approvers: requiredApprovers,
approver_roles: approverRoles.split(',').map((r) => r.trim()).filter(Boolean),
})
setName('')
setPriority(0)
setRequiredApprovers(1)
queryClient.invalidateQueries({ queryKey: ['mpc', 'policies'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Failed to create policy')
} finally {
setCreating(false)
}
}
async function handleToggle(id: string, enabled: boolean) {
try {
await api.updatePolicy(id, { enabled: !enabled })
queryClient.invalidateQueries({ queryKey: ['mpc', 'policies'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Failed to update policy')
}
}
async function handleDelete(id: string) {
try {
await api.deletePolicy(id)
queryClient.invalidateQueries({ queryKey: ['mpc', 'policies'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Failed to delete policy')
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Policies</h1>
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
Signing policies: auto-approve, requires-N, blocked.
</p>
</div>
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
{/* Create form */}
<form onSubmit={handleCreate} style={card}>
<div style={{ fontSize: 12, color: '#737373', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 12 }}>
Create Policy
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 8 }}>
<input style={input} placeholder="Policy name" required value={name} onChange={(e) => setName(e.target.value)} />
<select style={input} value={action} onChange={(e) => setAction(e.target.value)}>
{actionOptions.map((a) => <option key={a} value={a}>{a.replace(/_/g, ' ')}</option>)}
</select>
<input style={input} type="number" placeholder="Priority" value={priority} onChange={(e) => setPriority(Number(e.target.value))} />
<input style={input} type="number" min={1} placeholder="Approvers" value={requiredApprovers} onChange={(e) => setRequiredApprovers(Number(e.target.value))} />
</div>
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
<input style={{ ...input, flex: 1 }} placeholder="Approver roles (comma-separated)" value={approverRoles} onChange={(e) => setApproverRoles(e.target.value)} />
<button
type="submit"
disabled={creating || !name.trim()}
style={{ padding: '6px 16px', borderRadius: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', border: 'none', background: '#fff', color: '#000', opacity: creating || !name.trim() ? 0.5 : 1 }}
>
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
{/* Table */}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
<th style={{ padding: '8px 12px' }}>Name</th>
<th style={{ padding: '8px 12px' }}>Action</th>
<th style={{ padding: '8px 12px' }}>Priority</th>
<th style={{ padding: '8px 12px' }}>Approvers</th>
<th style={{ padding: '8px 12px' }}>Status</th>
<th style={{ padding: '8px 12px', textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{policies?.map((p) => (
<tr key={p.id} style={{ borderBottom: '1px solid #171717' }}>
<td style={{ padding: '8px 12px', fontWeight: 500 }}>{p.name}</td>
<td style={{ padding: '8px 12px', textTransform: 'capitalize' }}>{p.action.replace(/_/g, ' ')}</td>
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>{p.priority}</td>
<td style={{ padding: '8px 12px' }}>{p.required_approvers} ({p.approver_roles.join(', ')})</td>
<td style={{ padding: '8px 12px' }}><StatusBadge status={p.enabled ? 'enabled' : 'disabled'} /></td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
<button
onClick={() => handleToggle(p.id, p.enabled)}
style={{ background: 'none', border: 'none', color: '#a3a3a3', cursor: 'pointer', fontSize: 12, marginRight: 12 }}
>
{p.enabled ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => handleDelete(p.id)}
style={{ background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', fontSize: 12 }}
>
Delete
</button>
</td>
</tr>
))}
{(!policies || policies.length === 0) && (
<tr>
<td colSpan={6} style={{ padding: '16px 12px', color: '#525252' }}>
No policies configured
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
+116
View File
@@ -0,0 +1,116 @@
import { useInfo, useStatus } from '../hooks/use-mpc'
import { StatusBadge } from '../components/StatusBadge'
const card: React.CSSProperties = {
background: '#0a0a0a',
border: '1px solid #262626',
borderRadius: 8,
padding: 16,
}
const label: React.CSSProperties = {
fontSize: 12,
color: '#737373',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}
export function Settings() {
const { data: info } = useInfo()
const { data: cluster } = useStatus()
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Settings</h1>
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
Node configuration, HSM status, and threshold config.
</p>
</div>
{/* Service info */}
<div style={card}>
<div style={{ ...label, marginBottom: 12 }}>Service Info</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12 }}>
<div>
<div style={label}>Service</div>
<div style={{ fontSize: 14, marginTop: 2 }}>{info?.name ?? '--'}</div>
</div>
<div>
<div style={label}>Version</div>
<div style={{ fontSize: 14, fontFamily: 'monospace', marginTop: 2 }}>{info?.version ?? '--'}</div>
</div>
<div>
<div style={label}>Key Types</div>
<div style={{ fontSize: 14, fontFamily: 'monospace', marginTop: 2 }}>
{info?.key_types?.join(', ') ?? '--'}
</div>
</div>
<div>
<div style={label}>Protocols</div>
<div style={{ fontSize: 14, marginTop: 2 }}>
{info?.protocols?.join(', ') ?? '--'}
</div>
</div>
</div>
</div>
{/* Supported chains */}
<div style={card}>
<div style={{ ...label, marginBottom: 12 }}>Supported Chains</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{info?.supported_chains?.map((chain) => (
<span
key={chain}
style={{
background: '#262626',
borderRadius: 4,
padding: '4px 10px',
fontSize: 12,
color: '#d4d4d4',
}}
>
{chain}
</span>
))}
{(!info?.supported_chains || info.supported_chains.length === 0) && (
<span style={{ color: '#525252', fontSize: 13 }}>--</span>
)}
</div>
</div>
{/* Cluster config */}
<div style={card}>
<div style={{ ...label, marginBottom: 12 }}>Cluster Configuration</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12 }}>
<div>
<div style={label}>Node ID</div>
<div style={{ fontSize: 14, fontFamily: 'monospace', marginTop: 2 }}>{cluster?.node_id ?? '--'}</div>
</div>
<div>
<div style={label}>Mode</div>
<div style={{ fontSize: 14, marginTop: 2 }}>{cluster?.mode ?? '--'}</div>
</div>
<div>
<div style={label}>Threshold</div>
<div style={{ fontSize: 14, fontFamily: 'monospace', marginTop: 2 }}>
{cluster ? `${cluster.threshold}-of-${cluster.expected_peers}` : '--'}
</div>
</div>
<div>
<div style={label}>Peers</div>
<div style={{ fontSize: 14, fontFamily: 'monospace', marginTop: 2 }}>
{cluster ? `${cluster.connected_peers}/${cluster.expected_peers}` : '--'}
</div>
</div>
<div>
<div style={label}>Status</div>
<div style={{ marginTop: 2 }}>
{cluster ? <StatusBadge status={cluster.ready ? 'running' : 'degraded'} /> : '--'}
</div>
</div>
</div>
</div>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useState } from 'react'
import { useSettlements } from '../hooks/use-mpc'
import { StatusBadge } from '../components/StatusBadge'
const statusOptions = ['all', 'pending', 'hsm_signing', 'broadcast', 'confirming', 'finalized', 'verified', 'failed']
export function Settlements() {
const [statusFilter, setStatusFilter] = useState('all')
const filters = statusFilter !== 'all' ? { status: statusFilter } : undefined
const { data: settlements, error } = useSettlements(filters)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Settlements</h1>
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
Intent &rarr; sign &rarr; broadcast &rarr; confirm lifecycle.
</p>
</div>
{error && <div style={{ color: '#ef4444', fontSize: 13 }}>{(error as Error).message}</div>}
{/* Filter */}
<div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{
background: '#0a0a0a',
border: '1px solid #262626',
borderRadius: 6,
padding: '6px 12px',
color: '#e5e5e5',
fontSize: 13,
}}
>
{statusOptions.map((s) => (
<option key={s} value={s}>{s === 'all' ? 'All Status' : s.replace(/_/g, ' ')}</option>
))}
</select>
</div>
{/* Table */}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
<th style={{ padding: '8px 12px' }}>Settlement ID</th>
<th style={{ padding: '8px 12px' }}>Intent</th>
<th style={{ padding: '8px 12px' }}>HSM Sigs</th>
<th style={{ padding: '8px 12px' }}>Verified</th>
<th style={{ padding: '8px 12px' }}>Status</th>
<th style={{ padding: '8px 12px' }}>Date</th>
</tr>
</thead>
<tbody>
{settlements?.map((s) => (
<tr key={s.id} style={{ borderBottom: '1px solid #171717' }}>
<td style={{ padding: '8px 12px', fontFamily: 'monospace', fontSize: 11 }}>{s.id.slice(0, 12)}...</td>
<td style={{ padding: '8px 12px', fontFamily: 'monospace', fontSize: 11 }}>{s.intent_id.slice(0, 12)}...</td>
<td style={{ padding: '8px 12px' }}>{s.hsm_signatures?.length ?? 0}</td>
<td style={{ padding: '8px 12px' }}>
<span style={{ color: s.transfer_agency_verified ? '#22c55e' : '#737373' }}>
{s.transfer_agency_verified ? 'Yes' : 'No'}
</span>
</td>
<td style={{ padding: '8px 12px' }}><StatusBadge status={s.status} /></td>
<td style={{ padding: '8px 12px', color: '#737373' }}>
{new Date(s.created_at).toLocaleDateString()}
</td>
</tr>
))}
{(!settlements || settlements.length === 0) && (
<tr>
<td colSpan={6} style={{ padding: '16px 12px', color: '#525252' }}>
No settlements found
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useVaults } from '../hooks/use-mpc'
import { api } from '../lib/api'
const card: React.CSSProperties = {
background: '#0a0a0a',
border: '1px solid #262626',
borderRadius: 8,
padding: 16,
}
const input: React.CSSProperties = {
width: '100%',
background: '#0a0a0a',
border: '1px solid #262626',
borderRadius: 6,
padding: '6px 12px',
color: '#e5e5e5',
fontSize: 13,
}
const btn: React.CSSProperties = {
padding: '6px 16px',
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
cursor: 'pointer',
border: 'none',
}
export function Vaults() {
const queryClient = useQueryClient()
const { data: vaults } = useVaults()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [creating, setCreating] = useState(false)
const [err, setErr] = useState('')
async function handleCreate() {
if (!name.trim()) return
setCreating(true)
setErr('')
try {
await api.createVault({ name, description })
setName('')
setDescription('')
queryClient.invalidateQueries({ queryKey: ['mpc', 'vaults'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Failed to create vault')
} finally {
setCreating(false)
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Vaults</h1>
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
Cold storage vaults with Shamir backup status.
</p>
</div>
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
{/* Create form */}
<div style={card}>
<div style={{ fontSize: 12, color: '#737373', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 12 }}>
Create Vault
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input
style={{ ...input, maxWidth: 240 }}
placeholder="Vault name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
style={{ ...input, maxWidth: 320 }}
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<button
style={{ ...btn, background: '#fff', color: '#000', opacity: creating || !name.trim() ? 0.5 : 1 }}
disabled={creating || !name.trim()}
onClick={handleCreate}
>
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</div>
{/* Vault grid */}
{!vaults || vaults.length === 0 ? (
<div style={{ ...card, textAlign: 'center', color: '#525252' }}>
No vaults yet.
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
{vaults.map((v) => (
<div key={v.id} style={card}>
<div style={{ fontWeight: 500, color: '#fff' }}>{v.name}</div>
{v.description && (
<div style={{ fontSize: 12, color: '#737373', marginTop: 4 }}>{v.description}</div>
)}
<div style={{ fontSize: 11, color: '#525252', marginTop: 8, fontFamily: 'monospace' }}>
{v.id.slice(0, 16)}...
</div>
<div style={{ fontSize: 11, color: '#525252', marginTop: 2 }}>
Created {new Date(v.created_at).toLocaleDateString()}
</div>
</div>
))}
</div>
)}
</div>
)
}
+133
View File
@@ -0,0 +1,133 @@
import { useState } from 'react'
import { useVaults, useWallets } from '../hooks/use-mpc'
import { StatusBadge } from '../components/StatusBadge'
const card: React.CSSProperties = {
background: '#0a0a0a',
border: '1px solid #262626',
borderRadius: 8,
padding: 16,
}
const label: React.CSSProperties = {
fontSize: 12,
color: '#737373',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}
export function Wallets() {
const { data: vaults } = useVaults()
const [selectedVault, setSelectedVault] = useState('')
// Select first vault by default
const vaultId = selectedVault || vaults?.[0]?.id || ''
const { data: wallets } = useWallets(vaultId)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Wallets</h1>
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
MPC wallets grouped by vault. Each wallet holds threshold key shares.
</p>
</div>
{/* Vault selector */}
{vaults && vaults.length > 0 && (
<div>
<div style={label}>Vault</div>
<select
value={vaultId}
onChange={(e) => setSelectedVault(e.target.value)}
style={{
marginTop: 4,
background: '#0a0a0a',
border: '1px solid #262626',
borderRadius: 6,
padding: '6px 12px',
color: '#e5e5e5',
fontSize: 13,
}}
>
{vaults.map((v) => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
</div>
)}
{/* Wallet table */}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
<th style={{ padding: '8px 12px' }}>Name</th>
<th style={{ padding: '8px 12px' }}>Key Type</th>
<th style={{ padding: '8px 12px' }}>Protocol</th>
<th style={{ padding: '8px 12px' }}>Threshold</th>
<th style={{ padding: '8px 12px' }}>ETH Address</th>
<th style={{ padding: '8px 12px' }}>Status</th>
<th style={{ padding: '8px 12px' }}>Created</th>
</tr>
</thead>
<tbody>
{wallets?.map((w) => (
<tr key={w.id} style={{ borderBottom: '1px solid #171717' }}>
<td style={{ padding: '8px 12px', fontWeight: 500 }}>{w.name || w.wallet_id.slice(0, 12)}</td>
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>{w.key_type}</td>
<td style={{ padding: '8px 12px' }}>{w.protocol ?? '--'}</td>
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>
{w.threshold}/{w.participants.length}
</td>
<td style={{ padding: '8px 12px', fontFamily: 'monospace', fontSize: 11 }}>
{w.eth_address ? `${w.eth_address.slice(0, 10)}...${w.eth_address.slice(-6)}` : '--'}
</td>
<td style={{ padding: '8px 12px' }}><StatusBadge status={w.status} /></td>
<td style={{ padding: '8px 12px', color: '#737373' }}>
{new Date(w.created_at).toLocaleDateString()}
</td>
</tr>
))}
{(!wallets || wallets.length === 0) && (
<tr>
<td colSpan={7} style={{ padding: '16px 12px', color: '#525252' }}>
{vaultId ? 'No wallets in this vault' : 'No vaults available'}
</td>
</tr>
)}
</tbody>
</table>
{/* Wallet detail cards for addresses */}
{wallets && wallets.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12 }}>
{wallets.map((w) => (
<div key={w.id} style={card}>
<div style={{ fontWeight: 500, fontSize: 14, color: '#fff', marginBottom: 8 }}>
{w.name || w.wallet_id.slice(0, 12)}
</div>
{w.eth_address && (
<div style={{ fontSize: 11, color: '#a3a3a3', marginBottom: 4 }}>
<span style={label}>ETH</span>{' '}
<span style={{ fontFamily: 'monospace' }}>{w.eth_address}</span>
</div>
)}
{w.btc_address && (
<div style={{ fontSize: 11, color: '#a3a3a3', marginBottom: 4 }}>
<span style={label}>BTC</span>{' '}
<span style={{ fontFamily: 'monospace' }}>{w.btc_address}</span>
</div>
)}
{w.sol_address && (
<div style={{ fontSize: 11, color: '#a3a3a3' }}>
<span style={label}>SOL</span>{' '}
<span style={{ fontFamily: 'monospace' }}>{w.sol_address}</span>
</div>
)}
</div>
))}
</div>
)}
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/_/mpc/',
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
output: { manualChunks: undefined },
},
},
server: {
port: 3000,
proxy: {
'/v1/mpc': 'http://localhost:8081',
},
},
})