mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user