fix(ui): remove orphan pages blocking docker build

Users/Webhooks/Whitelist/APIKeys/Devices/Login/Operations and the
webauthn.ts shim referenced API methods, hooks, and types that were
never implemented in lib/api.ts or hooks/use-mpc.ts. None are routed
in App.tsx — pure dead code that broke `pnpm build` (and therefore
the Docker UI stage).

Routed pages remain: Dashboard, Wallets, Vaults, Policies,
Settlements, Bridge, Payments, Audit, Settings.

Unblocks ghcr.io/luxfi/mpc:v1.13.2 publish for #70 ZAP rollout.
This commit is contained in:
2026-05-01 01:55:28 -07:00
parent b55a416156
commit ed18fbea2d
8 changed files with 0 additions and 1326 deletions
-70
View File
@@ -1,70 +0,0 @@
// Browser <-> server WebAuthn glue. Translates the server's base64url-encoded
// PublicKeyCredentialCreationOptions into the browser's required ArrayBuffer
// shapes, runs navigator.credentials.create(), and serializes the result back
// to base64url for the /webauthn/register/complete endpoint.
import type { WebAuthnRegisterOptions, WebAuthnRegisterComplete } from './api'
function b64urlToBuffer(b64: string): ArrayBuffer {
// Backend uses base64.URLEncoding (with padding). atob accepts standard alphabet,
// so map -/_ to +// and let atob ignore padding.
const std = b64.replace(/-/g, '+').replace(/_/g, '/')
const bin = atob(std)
const buf = new ArrayBuffer(bin.length)
const view = new Uint8Array(buf)
for (let i = 0; i < bin.length; i++) view[i] = bin.charCodeAt(i)
return buf
}
function bytesToB64url(bytes: ArrayBuffer | Uint8Array): string {
const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
let bin = ''
for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i])
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_')
}
export async function runWebAuthnRegister(
options: WebAuthnRegisterOptions,
deviceName: string,
): Promise<WebAuthnRegisterComplete> {
if (!('credentials' in navigator)) {
throw new Error('WebAuthn unsupported in this browser')
}
const publicKey: PublicKeyCredentialCreationOptions = {
challenge: b64urlToBuffer(options.challenge),
rp: { id: options.rp.id, name: options.rp.name },
user: {
id: b64urlToBuffer(options.user.id),
name: options.user.name,
displayName: options.user.displayName,
},
pubKeyCredParams: options.pubKeyCredParams.map((p) => ({
type: p.type as PublicKeyCredentialType,
alg: p.alg,
})),
timeout: options.timeout,
attestation: options.attestation as AttestationConveyancePreference,
authenticatorSelection: {
authenticatorAttachment: options.authenticatorSelection.authenticatorAttachment as AuthenticatorAttachment,
userVerification: options.authenticatorSelection.userVerification as UserVerificationRequirement,
residentKey: options.authenticatorSelection.residentKey as ResidentKeyRequirement,
},
}
const cred = (await navigator.credentials.create({ publicKey })) as PublicKeyCredential | null
if (!cred) throw new Error('No credential returned by authenticator')
const att = cred.response as AuthenticatorAttestationResponse
return {
credential_id: options.credential_id,
id: cred.id,
rawId: bytesToB64url(cred.rawId),
type: cred.type,
response: {
attestationObject: bytesToB64url(att.attestationObject),
clientDataJSON: bytesToB64url(att.clientDataJSON),
},
device_name: deviceName,
}
}
-182
View File
@@ -1,182 +0,0 @@
import { useState, type FormEvent } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useAPIKeys } from '../hooks/use-mpc'
import { api } from '../lib/api'
import {
card, label, btn, btnGhost, btnDanger,
table, th, td, tHead, tRow, mono, h1, sub, errorText, disabledStyle,
} from '../lib/styles'
const presetPermissions = [
'mpc:sign',
'mpc:settlement:sign',
'mpc:operations:approve',
'mpc:wallet:sweep',
'mpc:policy:write',
'mpc:transactions:create',
'mpc:subscriptions:write',
'mpc:payment-requests:write',
'mpc:smart-wallets:write',
'mpc:trade:write',
'mpc.treasury.admin',
'mpc.treasury.sign',
'sign',
'trade:submit',
]
export function APIKeys() {
const qc = useQueryClient()
const { data: keys, error } = useAPIKeys()
const [name, setName] = useState('')
const [perms, setPerms] = useState<Set<string>>(new Set())
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [revealed, setRevealed] = useState<{ name: string; key: string } | null>(null)
function toggle(p: string) {
const n = new Set(perms)
if (n.has(p)) n.delete(p); else n.add(p)
setPerms(n)
}
async function create(e: FormEvent) {
e.preventDefault()
if (!name.trim()) return
setBusy(true); setErr('')
try {
const r = await api.createAPIKey({
name: name.trim(),
permissions: Array.from(perms),
})
setRevealed({ name: r.name, key: r.key })
setName(''); setPerms(new Set())
qc.invalidateQueries({ queryKey: ['mpc', 'api-keys'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Create failed')
} finally {
setBusy(false)
}
}
async function remove(id: string) {
try {
await api.deleteAPIKey(id)
qc.invalidateQueries({ queryKey: ['mpc', 'api-keys'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Delete failed')
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1 style={h1}>API Keys</h1>
<p style={sub}>
Service-account credentials. Keys are shown once at creation; capture them then.
</p>
</div>
{err && <div style={errorText}>{err}</div>}
{revealed && (
<div style={{ ...card, borderColor: '#22c55e' }}>
<div style={{ ...label, color: '#22c55e' }}>Key created — copy now</div>
<div style={{ marginTop: 4, fontSize: 13 }}>{revealed.name}</div>
<div style={{ marginTop: 8, padding: 8, background: '#000', borderRadius: 4, ...mono, fontSize: 12, wordBreak: 'break-all' }}>
{revealed.key}
</div>
<button
onClick={() => setRevealed(null)}
style={{ ...btnGhost, marginTop: 12, padding: '4px 12px' }}
>
Dismiss
</button>
</div>
)}
<form onSubmit={create} style={card}>
<div style={{ ...label, marginBottom: 8 }}>New API key</div>
<input
placeholder="Key name (e.g. ats-service-account)"
required
value={name}
onChange={(e) => setName(e.target.value)}
style={{
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
padding: '6px 12px', color: '#e5e5e5', fontSize: 13, width: '100%',
}}
/>
<div style={{ ...label, marginTop: 12 }}>Permissions (must enumerate; wildcard `*` is rejected)</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{presetPermissions.map((p) => {
const on = perms.has(p)
return (
<button
key={p}
type="button"
onClick={() => toggle(p)}
style={{
...btnGhost,
padding: '3px 10px', fontSize: 11, ...mono,
...(on ? { background: '#22c55e20', color: '#22c55e', borderColor: '#22c55e' } : {}),
}}
>
{p}
</button>
)
})}
</div>
<button
type="submit"
disabled={busy || !name.trim() || perms.size === 0}
style={{ ...btn, marginTop: 12, ...disabledStyle(busy || !name.trim() || perms.size === 0) }}
>
{busy ? 'Creating...' : 'Create key'}
</button>
</form>
{error && <div style={errorText}>{(error as Error).message}</div>}
<table style={table}>
<thead>
<tr style={tHead}>
<th style={th}>Name</th>
<th style={th}>Prefix</th>
<th style={th}>Permissions</th>
<th style={th}>Last used</th>
<th style={th}>Created</th>
<th style={{ ...th, textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{(keys ?? []).map((k) => (
<tr key={k.id} style={tRow}>
<td style={td}>{k.name}</td>
<td style={{ ...td, ...mono, fontSize: 11 }}>{k.keyPrefix}…</td>
<td style={{ ...td, fontSize: 11, color: '#a3a3a3' }}>
{k.permissions.length > 0 ? k.permissions.join(', ') : <span style={{ color: '#525252' }}>none</span>}
</td>
<td style={{ ...td, color: '#737373' }}>{k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleString() : '--'}</td>
<td style={{ ...td, color: '#737373' }}>{new Date(k.createdAt).toLocaleString()}</td>
<td style={{ ...td, textAlign: 'right' }}>
<button
onClick={() => remove(k.id)}
style={{ ...btnDanger, padding: '4px 12px', background: 'transparent', color: '#ef4444', border: '1px solid #ef4444' }}
>
Revoke
</button>
</td>
</tr>
))}
{(keys ?? []).length === 0 && (
<tr>
<td colSpan={6} style={{ ...td, color: '#525252' }}>
No API keys.
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
-125
View File
@@ -1,125 +0,0 @@
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useWebAuthnCredentials } from '../hooks/use-mpc'
import { api } from '../lib/api'
import { runWebAuthnRegister } from '../lib/webauthn'
import {
card, label, btn, btnGhost,
table, th, td, tHead, tRow, mono, h1, sub, errorText, disabledStyle, shorten,
} from '../lib/styles'
export function Devices() {
const qc = useQueryClient()
const { data: creds, error } = useWebAuthnCredentials()
const [deviceName, setDeviceName] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [info, setInfo] = useState('')
async function pair() {
if (!deviceName.trim()) return
setBusy(true); setErr(''); setInfo('')
try {
const opts = await api.webAuthnRegisterBegin()
const completed = await runWebAuthnRegister(opts, deviceName.trim())
const r = await api.webAuthnRegisterComplete(completed)
setInfo(`Paired ${r.credential_id}`)
setDeviceName('')
qc.invalidateQueries({ queryKey: ['mpc', 'webauthn'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Pairing failed')
} finally {
setBusy(false)
}
}
async function revoke(id: string) {
setBusy(true); setErr('')
try {
await api.deleteWebAuthnCredential(id)
qc.invalidateQueries({ queryKey: ['mpc', 'webauthn'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Revoke failed')
} finally {
setBusy(false)
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1 style={h1}>Devices</h1>
<p style={sub}>
WebAuthn authenticators for biometric and hardware-key approval. Touch ID, Face ID, Windows Hello, YubiKey.
</p>
</div>
{err && <div style={errorText}>{err}</div>}
{info && <div style={{ color: '#22c55e', fontSize: 13 }}>{info}</div>}
<div style={card}>
<div style={{ ...label, marginBottom: 8 }}>Pair new device</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input
placeholder="Device label (e.g. MacBook Touch ID)"
value={deviceName}
onChange={(e) => setDeviceName(e.target.value)}
style={{
flex: 1, minWidth: 240,
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
}}
/>
<button
onClick={pair}
disabled={busy || !deviceName.trim()}
style={{ ...btn, ...disabledStyle(busy || !deviceName.trim()) }}
>
{busy ? 'Authenticating...' : 'Pair'}
</button>
</div>
<div style={{ fontSize: 11, color: '#525252', marginTop: 8 }}>
The browser will prompt your platform authenticator. Ensure user verification is available.
</div>
</div>
{error && <div style={errorText}>{(error as Error).message}</div>}
<table style={table}>
<thead>
<tr style={tHead}>
<th style={th}>Device</th>
<th style={th}>Credential ID</th>
<th style={th}>Created</th>
<th style={{ ...th, textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{(creds ?? []).map((c) => (
<tr key={c.id} style={tRow}>
<td style={td}>{c.device_name ?? '--'}</td>
<td style={{ ...td, ...mono, fontSize: 11 }}>{shorten(c.id, 10, 6)}</td>
<td style={{ ...td, color: '#737373' }}>{new Date(c.created_at).toLocaleString()}</td>
<td style={{ ...td, textAlign: 'right' }}>
<button
onClick={() => revoke(c.id)}
disabled={busy}
style={{ ...btnGhost, padding: '4px 12px', ...disabledStyle(busy) }}
>
Revoke
</button>
</td>
</tr>
))}
{(creds ?? []).length === 0 && (
<tr>
<td colSpan={4} style={{ ...td, color: '#525252' }}>
No paired devices.
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
-109
View File
@@ -1,109 +0,0 @@
import { useState, type FormEvent } from 'react'
import { useLocation } from 'wouter'
import { api, auth } from '../lib/api'
import { card, input, btn, label, h1, sub, errorText, disabledStyle } from '../lib/styles'
export function Login() {
const [, navigate] = useLocation()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [mfa, setMfa] = useState('')
const [mfaRequired, setMfaRequired] = useState(false)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
async function onSubmit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setErr('')
try {
const r = await api.login(email, password, mfa || undefined)
if (r.mfa_required) {
setMfaRequired(true)
return
}
auth.set(r.access_token, r.refresh_token, {
id: r.user_id,
org_id: r.org_id,
role: r.role,
email,
})
navigate('/')
} catch (e) {
setErr(e instanceof Error ? e.message : 'Login failed')
} finally {
setBusy(false)
}
}
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#000',
color: '#e5e5e5',
padding: 24,
}}
>
<form onSubmit={onSubmit} style={{ ...card, width: 360 }}>
<div style={{ ...h1, marginBottom: 4 }}>MPC Admin</div>
<div style={sub}>Sign in to manage threshold wallets, policies, and operations.</div>
<div style={{ marginTop: 24, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<div style={label}>Email</div>
<input
style={{ ...input, marginTop: 4 }}
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={mfaRequired}
/>
</div>
<div>
<div style={label}>Password</div>
<input
style={{ ...input, marginTop: 4 }}
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={mfaRequired}
/>
</div>
{mfaRequired && (
<div>
<div style={label}>MFA Code</div>
<input
style={{ ...input, marginTop: 4, fontFamily: 'monospace' }}
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
required
autoFocus
value={mfa}
onChange={(e) => setMfa(e.target.value)}
/>
</div>
)}
{err && <div style={errorText}>{err}</div>}
<button
type="submit"
disabled={busy}
style={{ ...btn, marginTop: 8, ...disabledStyle(busy) }}
>
{busy ? 'Signing in...' : mfaRequired ? 'Verify' : 'Sign in'}
</button>
</div>
</form>
</div>
)
}
-330
View File
@@ -1,330 +0,0 @@
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useOperations } from '../hooks/use-mpc'
import { api, type Operation } from '../lib/api'
import { StatusBadge } from '../components/StatusBadge'
import {
card, label, btn, btnGhost, btnDanger,
table, th, td, tHead, tRow, mono, h1, sub, errorText, disabledStyle, shorten,
} from '../lib/styles'
const statusFilters = [
{ v: '', l: 'All' },
{ v: 'pending_approval', l: 'Pending approval' },
{ v: 'approved', l: 'Approved' },
{ v: 'signed', l: 'Signed' },
{ v: 'rejected', l: 'Rejected' },
{ v: 'failed', l: 'Failed' },
{ v: 'expired', l: 'Expired' },
]
export function Operations() {
const [status, setStatus] = useState('pending_approval')
const [page, setPage] = useState(1)
const filters = { status: status || undefined, page, perPage: 25 }
const { data, error } = useOperations(filters)
const [selected, setSelected] = useState<Operation | null>(null)
const items = data?.items ?? []
const total = data?.totalItems ?? 0
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1 style={h1}>Operations</h1>
<p style={sub}>
Approval inbox. Pending intents need your signature; approved ones are signing now.
</p>
</div>
{error && <div style={errorText}>{(error as Error).message}</div>}
{/* Filter bar */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{statusFilters.map((f) => (
<button
key={f.v}
onClick={() => { setStatus(f.v); setPage(1) }}
style={{
...btnGhost,
padding: '4px 12px',
fontSize: 12,
...(status === f.v ? { background: '#fff', color: '#000', border: '1px solid #fff' } : {}),
}}
>
{f.l}
</button>
))}
<div style={{ marginLeft: 'auto', fontSize: 12, color: '#737373' }}>
{total} total
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: selected ? 'minmax(0, 1fr) 420px' : '1fr', gap: 16 }}>
{/* Table */}
<div>
<table style={table}>
<thead>
<tr style={tHead}>
<th style={th}>Op ID</th>
<th style={th}>Kind</th>
<th style={th}>Wallet</th>
<th style={th}>To</th>
<th style={th}>Amount</th>
<th style={th}>Approvals</th>
<th style={th}>Status</th>
<th style={th}>Created</th>
</tr>
</thead>
<tbody>
{items.map((op) => {
const to = (op.payload?.toAddress as string) ?? ''
const amount = (op.payload?.amount as string) ?? ''
const chain = (op.payload?.chain as string) ?? ''
const isSelected = selected?.operationId === op.operationId
return (
<tr
key={op.operationId}
onClick={() => setSelected(op)}
style={{
...tRow,
cursor: 'pointer',
background: isSelected ? '#171717' : 'transparent',
}}
>
<td style={{ ...td, ...mono, fontSize: 11 }}>{shorten(op.operationId, 8, 4)}</td>
<td style={td}>{op.kind}</td>
<td style={{ ...td, ...mono, fontSize: 11 }}>{shorten(op.walletId, 8, 4)}</td>
<td style={{ ...td, ...mono, fontSize: 11 }}>{to ? shorten(to, 8, 4) : '--'}</td>
<td style={{ ...td, ...mono }}>
{amount ? `${amount} ${chain}` : '--'}
</td>
<td style={td}>{op.approvers?.length ?? 0}</td>
<td style={td}><StatusBadge status={op.status} /></td>
<td style={{ ...td, color: '#737373' }}>
{new Date(op.createdAt).toLocaleString()}
</td>
</tr>
)
})}
{items.length === 0 && (
<tr>
<td colSpan={8} style={{ ...td, color: '#525252' }}>
No operations match this filter.
</td>
</tr>
)}
</tbody>
</table>
{/* Pagination */}
{total > 25 && (
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
style={{ ...btnGhost, ...disabledStyle(page === 1) }}
>
Prev
</button>
<span style={{ fontSize: 12, color: '#737373' }}>
Page {page} of {Math.ceil(total / 25)}
</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={page * 25 >= total}
style={{ ...btnGhost, ...disabledStyle(page * 25 >= total) }}
>
Next
</button>
</div>
)}
</div>
{/* Detail panel */}
{selected && (
<OperationDetail
op={selected}
onClose={() => setSelected(null)}
onChange={() => {/* tanstack-query refetches via interval */}}
/>
)}
</div>
</div>
)
}
function OperationDetail({
op, onClose, onChange,
}: {
op: Operation
onClose: () => void
onChange: () => void
}) {
const qc = useQueryClient()
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [reason, setReason] = useState('')
const [notes, setNotes] = useState('')
const [showReject, setShowReject] = useState(false)
async function approve() {
setBusy(true); setErr('')
try {
await api.approveOperation(op.operationId, notes)
qc.invalidateQueries({ queryKey: ['mpc', 'operations'] })
onChange()
} catch (e) {
setErr(e instanceof Error ? e.message : 'Approval failed')
} finally {
setBusy(false)
}
}
async function reject() {
if (!reason.trim()) return
setBusy(true); setErr('')
try {
await api.rejectOperation(op.operationId, reason)
qc.invalidateQueries({ queryKey: ['mpc', 'operations'] })
onChange()
onClose()
} catch (e) {
setErr(e instanceof Error ? e.message : 'Rejection failed')
} finally {
setBusy(false)
}
}
const canDecide = op.status === 'pending_approval' || op.status === 'approved'
return (
<div style={{ ...card, alignSelf: 'start', position: 'sticky', top: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ fontWeight: 600, color: '#fff' }}>Operation</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#737373', cursor: 'pointer', fontSize: 18 }}>×</button>
</div>
<div style={{ ...mono, fontSize: 11, color: '#737373', marginTop: 4 }}>{op.operationId}</div>
<Field title="Kind" value={op.kind} />
<Field title="Status" value={<StatusBadge status={op.status} />} />
<Field title="Wallet" value={<span style={mono}>{op.walletId}</span>} />
{op.payload && Object.keys(op.payload).length > 0 && (
<div style={{ marginTop: 16 }}>
<div style={label}>Payload</div>
<div style={{ marginTop: 4, padding: 8, background: '#000', border: '1px solid #171717', borderRadius: 4 }}>
{Object.entries(op.payload).map(([k, v]) => (
<div key={k} style={{ fontSize: 11, ...mono, color: '#a3a3a3', wordBreak: 'break-all' }}>
<span style={{ color: '#737373' }}>{k}:</span>{' '}
{typeof v === 'string' ? v : JSON.stringify(v)}
</div>
))}
</div>
</div>
)}
{op.approvals && op.approvals.length > 0 && (
<div style={{ marginTop: 16 }}>
<div style={label}>Approvals</div>
{op.approvals.map((a) => (
<div key={a.approverId} style={{ marginTop: 4, fontSize: 12 }}>
<span style={mono}>{shorten(a.approverId, 8, 4)}</span>{' '}
<span style={{ color: '#737373' }}>{new Date(a.approvedAt).toLocaleString()}</span>
</div>
))}
</div>
)}
{op.result && Object.keys(op.result).length > 0 && (
<div style={{ marginTop: 16 }}>
<div style={label}>Result</div>
{Object.entries(op.result).map(([k, v]) => (
<div key={k} style={{ fontSize: 11, ...mono, color: '#a3a3a3', wordBreak: 'break-all', marginTop: 4 }}>
<span style={{ color: '#737373' }}>{k}:</span> {v}
</div>
))}
</div>
)}
{op.rejectionReason && (
<div style={{ marginTop: 16 }}>
<div style={label}>Rejection reason</div>
<div style={{ fontSize: 13, color: '#ef4444', marginTop: 4 }}>{op.rejectionReason}</div>
</div>
)}
{err && <div style={{ ...errorText, marginTop: 12 }}>{err}</div>}
{canDecide && (
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
{!showReject && (
<>
<input
placeholder="Optional notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
style={{
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
}}
/>
<button
onClick={approve}
disabled={busy}
style={{ ...btn, ...disabledStyle(busy) }}
>
{busy ? 'Approving...' : 'Approve'}
</button>
<button
onClick={() => setShowReject(true)}
disabled={busy}
style={{ ...btnGhost, ...disabledStyle(busy) }}
>
Reject
</button>
</>
)}
{showReject && (
<>
<input
autoFocus
placeholder="Reason (required)"
value={reason}
onChange={(e) => setReason(e.target.value)}
style={{
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
}}
/>
<button
onClick={reject}
disabled={busy || !reason.trim()}
style={{ ...btnDanger, ...disabledStyle(busy || !reason.trim()) }}
>
{busy ? 'Rejecting...' : 'Confirm reject'}
</button>
<button
onClick={() => { setShowReject(false); setReason('') }}
disabled={busy}
style={{ ...btnGhost, ...disabledStyle(busy) }}
>
Cancel
</button>
</>
)}
</div>
)}
</div>
)
}
function Field({ title, value }: { title: string; value: React.ReactNode }) {
return (
<div style={{ marginTop: 12 }}>
<div style={label}>{title}</div>
<div style={{ fontSize: 13, marginTop: 2 }}>{value}</div>
</div>
)
}
-172
View File
@@ -1,172 +0,0 @@
import { useState, type FormEvent } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useUsers } from '../hooks/use-mpc'
import { api, auth } from '../lib/api'
import {
card, label, btn, btnGhost,
table, th, td, tHead, tRow, mono, h1, sub, errorText, disabledStyle, shorten,
} from '../lib/styles'
const roles = ['viewer', 'signer', 'admin', 'owner']
export function Users() {
const qc = useQueryClient()
const { data: users, error } = useUsers()
const me = auth.user()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [role, setRole] = useState('viewer')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
async function invite(e: FormEvent) {
e.preventDefault()
if (!email.trim() || !password) return
setBusy(true); setErr('')
try {
await api.inviteUser({ email: email.trim(), password, role })
setEmail(''); setPassword(''); setRole('viewer')
qc.invalidateQueries({ queryKey: ['mpc', 'users'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Invite failed')
} finally {
setBusy(false)
}
}
async function changeRole(id: string, newRole: string) {
try {
await api.updateUser(id, { role: newRole })
qc.invalidateQueries({ queryKey: ['mpc', 'users'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Update failed')
}
}
async function remove(id: string) {
try {
await api.deleteUser(id)
qc.invalidateQueries({ queryKey: ['mpc', 'users'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Delete failed')
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1 style={h1}>Users</h1>
<p style={sub}>
Org members with role-based permissions. Roles: viewer, signer, admin, owner.
</p>
</div>
{err && <div style={errorText}>{err}</div>}
<form onSubmit={invite} style={card}>
<div style={{ ...label, marginBottom: 8 }}>Invite user</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 200px 140px auto', gap: 8 }}>
<input
type="email"
placeholder="[email protected]"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
}}
/>
<input
type="password"
placeholder="Initial password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
style={{
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
}}
/>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
style={{
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
}}
>
{roles.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
<button
type="submit"
disabled={busy || !email.trim() || !password}
style={{ ...btn, ...disabledStyle(busy || !email.trim() || !password) }}
>
{busy ? 'Inviting...' : 'Invite'}
</button>
</div>
</form>
{error && <div style={errorText}>{(error as Error).message}</div>}
<table style={table}>
<thead>
<tr style={tHead}>
<th style={th}>Email</th>
<th style={th}>Role</th>
<th style={th}>ID</th>
<th style={th}>Created</th>
<th style={{ ...th, textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{(users ?? []).map((u) => {
const isMe = me?.id === u.id
return (
<tr key={u.id} style={tRow}>
<td style={td}>
{u.email} {isMe && <span style={{ fontSize: 11, color: '#22c55e', marginLeft: 4 }}>(you)</span>}
</td>
<td style={td}>
<select
value={u.role}
onChange={(e) => changeRole(u.id, e.target.value)}
disabled={isMe}
style={{
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 4,
padding: '2px 8px', color: '#e5e5e5', fontSize: 12,
}}
>
{roles.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</td>
<td style={{ ...td, ...mono, fontSize: 11 }}>{shorten(u.id, 10, 4)}</td>
<td style={{ ...td, color: '#737373' }}>{new Date(u.createdAt).toLocaleString()}</td>
<td style={{ ...td, textAlign: 'right' }}>
<button
onClick={() => remove(u.id)}
disabled={isMe}
style={{
...btnGhost, padding: '3px 10px', color: '#ef4444', borderColor: '#ef4444',
...disabledStyle(isMe),
}}
>
Remove
</button>
</td>
</tr>
)
})}
{(users ?? []).length === 0 && (
<tr>
<td colSpan={5} style={{ ...td, color: '#525252' }}>
No users.
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
-196
View File
@@ -1,196 +0,0 @@
import { useState, type FormEvent } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useWebhooks } from '../hooks/use-mpc'
import { api } from '../lib/api'
import { StatusBadge } from '../components/StatusBadge'
import {
card, label, btn, btnGhost,
table, th, td, tHead, tRow, mono, h1, sub, errorText, disabledStyle,
} from '../lib/styles'
const eventOptions = [
'transaction.created',
'transaction.signed',
'transaction.broadcast',
'transaction.confirmed',
'transaction.failed',
'operation.pending_approval',
'operation.approved',
'operation.rejected',
'wallet.created',
'policy.violation',
'session.created',
'session.revoked',
]
export function Webhooks() {
const qc = useQueryClient()
const { data: webhooks, error } = useWebhooks()
const [url, setUrl] = useState('')
const [secret, setSecret] = useState('')
const [events, setEvents] = useState<Set<string>>(new Set())
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [info, setInfo] = useState('')
function toggle(e: string) {
const n = new Set(events)
if (n.has(e)) n.delete(e); else n.add(e)
setEvents(n)
}
async function create(e: FormEvent) {
e.preventDefault()
if (!url.trim() || !secret.trim() || events.size === 0) return
setBusy(true); setErr(''); setInfo('')
try {
await api.createWebhook({
url: url.trim(),
secret: secret.trim(),
events: Array.from(events),
})
setUrl(''); setSecret(''); setEvents(new Set())
qc.invalidateQueries({ queryKey: ['mpc', 'webhooks'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Create failed')
} finally {
setBusy(false)
}
}
async function toggleEnabled(id: string, enabled: boolean) {
try {
await api.updateWebhook(id, { enabled: !enabled })
qc.invalidateQueries({ queryKey: ['mpc', 'webhooks'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Update failed')
}
}
async function test(id: string) {
try {
const r = await api.testWebhook(id)
setInfo(`Test sent: ${r.status}`)
} catch (e) {
setErr(e instanceof Error ? e.message : 'Test failed')
}
}
async function remove(id: string) {
try {
await api.deleteWebhook(id)
qc.invalidateQueries({ queryKey: ['mpc', 'webhooks'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Delete failed')
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1 style={h1}>Webhooks</h1>
<p style={sub}>
Outbound HTTP delivery of MPC events. Each request is HMAC-signed with the webhook secret.
</p>
</div>
{err && <div style={errorText}>{err}</div>}
{info && <div style={{ color: '#22c55e', fontSize: 13 }}>{info}</div>}
<form onSubmit={create} style={card}>
<div style={{ ...label, marginBottom: 8 }}>New webhook</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<input
placeholder="https://example.com/webhook"
required
value={url}
onChange={(e) => setUrl(e.target.value)}
style={{
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
}}
/>
<input
placeholder="Shared HMAC secret"
required
value={secret}
onChange={(e) => setSecret(e.target.value)}
style={{
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
padding: '6px 12px', color: '#e5e5e5', fontSize: 13, ...mono,
}}
/>
</div>
<div style={{ ...label, marginTop: 12 }}>Events</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{eventOptions.map((ev) => {
const on = events.has(ev)
return (
<button
key={ev}
type="button"
onClick={() => toggle(ev)}
style={{
...btnGhost, padding: '3px 10px', fontSize: 11, ...mono,
...(on ? { background: '#3b82f620', color: '#3b82f6', borderColor: '#3b82f6' } : {}),
}}
>
{ev}
</button>
)
})}
</div>
<button
type="submit"
disabled={busy || !url.trim() || !secret.trim() || events.size === 0}
style={{
...btn, marginTop: 12,
...disabledStyle(busy || !url.trim() || !secret.trim() || events.size === 0),
}}
>
{busy ? 'Creating...' : 'Create webhook'}
</button>
</form>
{error && <div style={errorText}>{(error as Error).message}</div>}
<table style={table}>
<thead>
<tr style={tHead}>
<th style={th}>URL</th>
<th style={th}>Events</th>
<th style={th}>Status</th>
<th style={th}>Created</th>
<th style={{ ...th, textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{(webhooks ?? []).map((w) => (
<tr key={w.id} style={tRow}>
<td style={{ ...td, ...mono, fontSize: 11 }}>{w.url}</td>
<td style={{ ...td, fontSize: 11, color: '#a3a3a3' }}>{w.events.join(', ')}</td>
<td style={td}><StatusBadge status={w.enabled ? 'enabled' : 'disabled'} /></td>
<td style={{ ...td, color: '#737373' }}>{new Date(w.created_at).toLocaleString()}</td>
<td style={{ ...td, textAlign: 'right' }}>
<button onClick={() => test(w.id)} style={{ ...btnGhost, padding: '3px 10px', marginRight: 4 }}>Test</button>
<button onClick={() => toggleEnabled(w.id, w.enabled)} style={{ ...btnGhost, padding: '3px 10px', marginRight: 4 }}>
{w.enabled ? 'Disable' : 'Enable'}
</button>
<button onClick={() => remove(w.id)} style={{ ...btnGhost, padding: '3px 10px', color: '#ef4444', borderColor: '#ef4444' }}>
Delete
</button>
</td>
</tr>
))}
{(webhooks ?? []).length === 0 && (
<tr>
<td colSpan={5} style={{ ...td, color: '#525252' }}>
No webhooks configured.
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
-142
View File
@@ -1,142 +0,0 @@
import { useState, type FormEvent } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useWhitelist } from '../hooks/use-mpc'
import { api } from '../lib/api'
import {
card, label, btn, btnGhost,
table, th, td, tHead, tRow, mono, h1, sub, errorText, disabledStyle,
} from '../lib/styles'
const chains = ['ethereum', 'bitcoin', 'lux', 'solana', 'polygon', 'arbitrum', 'base', 'optimism']
export function Whitelist() {
const qc = useQueryClient()
const { data: entries, error } = useWhitelist()
const [address, setAddress] = useState('')
const [chain, setChain] = useState('ethereum')
const [labelText, setLabelText] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
async function add(e: FormEvent) {
e.preventDefault()
if (!address.trim()) return
setBusy(true); setErr('')
try {
await api.addWhitelist({
address: address.trim(),
chain,
label: labelText.trim() || undefined,
})
setAddress(''); setLabelText('')
qc.invalidateQueries({ queryKey: ['mpc', 'whitelist'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Add failed')
} finally {
setBusy(false)
}
}
async function remove(id: string) {
try {
await api.deleteWhitelist(id)
qc.invalidateQueries({ queryKey: ['mpc', 'whitelist'] })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Remove failed')
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1 style={h1}>Allowlist</h1>
<p style={sub}>
Permit destination addresses that policies can require for transfers.
</p>
</div>
{err && <div style={errorText}>{err}</div>}
<form onSubmit={add} style={card}>
<div style={{ ...label, marginBottom: 8 }}>Add destination</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 160px 200px auto', gap: 8 }}>
<input
placeholder="Address"
required
value={address}
onChange={(e) => setAddress(e.target.value)}
style={{
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
padding: '6px 12px', color: '#e5e5e5', fontSize: 13, fontFamily: 'monospace',
}}
/>
<select
value={chain}
onChange={(e) => setChain(e.target.value)}
style={{
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
}}
>
{chains.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<input
placeholder="Label (optional)"
value={labelText}
onChange={(e) => setLabelText(e.target.value)}
style={{
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
}}
/>
<button
type="submit"
disabled={busy || !address.trim()}
style={{ ...btn, ...disabledStyle(busy || !address.trim()) }}
>
{busy ? 'Adding...' : 'Add'}
</button>
</div>
</form>
{error && <div style={errorText}>{(error as Error).message}</div>}
<table style={table}>
<thead>
<tr style={tHead}>
<th style={th}>Address</th>
<th style={th}>Chain</th>
<th style={th}>Label</th>
<th style={th}>Created</th>
<th style={{ ...th, textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{(entries ?? []).map((e) => (
<tr key={e.id} style={tRow}>
<td style={{ ...td, ...mono, fontSize: 11 }}>{e.address}</td>
<td style={td}>{e.chain}</td>
<td style={td}>{e.label || '--'}</td>
<td style={{ ...td, color: '#737373' }}>{new Date(e.created_at).toLocaleString()}</td>
<td style={{ ...td, textAlign: 'right' }}>
<button
onClick={() => remove(e.id)}
style={{ ...btnGhost, padding: '4px 12px' }}
>
Remove
</button>
</td>
</tr>
))}
{(entries ?? []).length === 0 && (
<tr>
<td colSpan={5} style={{ ...td, color: '#525252' }}>
No allowlisted destinations.
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}