mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
feat: MPC wallet dashboard - full-stack implementation
Complete MPC wallet management platform rivaling Fireblocks/Utila/Fordefi: Backend (Go): - REST API with 60+ endpoints on chi router (pkg/api/) - PostgreSQL schema with 13 tables (pkg/db/) - JWT + MFA auth, bcrypt passwords, API key management - Policy engine (deny/approve/require_approval) - Transaction signing flow with approval workflow - Subscription scheduler for recurring payments - Payment requests with public pay pages - Safe + ERC-4337 smart wallet integration (pkg/smart/) - Rate limiting, webhook delivery (HMAC-SHA256), audit logging - 27 passing tests (auth, policy engine, middleware) Frontend (Next.js 14): - 27 routes with monochrome dark theme (Tailwind + Geist fonts) - Full API client with JWT auto-refresh - React Query hooks for all resources - Pages: dashboard, vaults, wallets, transactions, policies, whitelist, team, settings, API keys, webhooks, audit, subscriptions, payments, smart wallets Infrastructure: - Dockerfiles for API (Go) and dashboard (Next.js standalone) - compose.yml for local development (PostgreSQL + API + dashboard) - K8s manifests for mpc.lux.network + cloud.lux.network - GitHub Actions deploy workflow (build → push GHCR → kubectl apply) - TLS via cert-manager, Traefik IngressRoute for both domains
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
*.md
|
||||
dashboard/node_modules
|
||||
dashboard/.next
|
||||
node_modules
|
||||
.env
|
||||
@@ -0,0 +1,8 @@
|
||||
# PostgreSQL
|
||||
POSTGRES_PASSWORD=your-secure-password-here
|
||||
|
||||
# JWT Secret (generate with: openssl rand -hex 32)
|
||||
JWT_SECRET=your-jwt-secret-here
|
||||
|
||||
# API URL for dashboard
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8081/api/v1
|
||||
@@ -0,0 +1,130 @@
|
||||
name: Build & Deploy MPC Dashboard
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
build-api:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/luxfi/mpc-api
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push API
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
provenance: false
|
||||
sbom: false
|
||||
cache-from: type=gha,scope=api
|
||||
cache-to: type=gha,mode=max,scope=api
|
||||
|
||||
build-dashboard:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/luxfi/mpc-dashboard
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Dashboard
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./dashboard
|
||||
file: ./dashboard/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
provenance: false
|
||||
sbom: false
|
||||
cache-from: type=gha,scope=dashboard
|
||||
cache-to: type=gha,mode=max,scope=dashboard
|
||||
build-args: |
|
||||
NEXT_PUBLIC_API_URL=https://mpc.lux.network/api/v1
|
||||
|
||||
deploy:
|
||||
needs: [build-api, build-dashboard]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install doctl
|
||||
uses: digitalocean/action-doctl@v2
|
||||
with:
|
||||
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
|
||||
|
||||
- name: Configure kubectl
|
||||
run: doctl kubernetes cluster kubeconfig save lux-k8s
|
||||
|
||||
- name: Apply K8s manifests
|
||||
run: |
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/secrets.yaml
|
||||
kubectl apply -f k8s/certificate.yaml
|
||||
kubectl apply -f k8s/postgres.yaml
|
||||
kubectl apply -f k8s/api-service.yaml
|
||||
kubectl apply -f k8s/api-deployment.yaml
|
||||
kubectl apply -f k8s/dashboard-service.yaml
|
||||
kubectl apply -f k8s/dashboard-deployment.yaml
|
||||
kubectl apply -f k8s/ingressroute.yaml
|
||||
|
||||
- name: Restart deployments
|
||||
run: |
|
||||
kubectl rollout restart deployment/mpc-api -n lux-mpc
|
||||
kubectl rollout restart deployment/mpc-dashboard -n lux-mpc
|
||||
|
||||
- name: Wait for rollout
|
||||
run: |
|
||||
kubectl rollout status deployment/mpc-api -n lux-mpc --timeout=120s
|
||||
kubectl rollout status deployment/mpc-dashboard -n lux-mpc --timeout=120s
|
||||
+22
-7
@@ -1,6 +1,6 @@
|
||||
db/
|
||||
tmp/
|
||||
bin/
|
||||
/db/
|
||||
/tmp/
|
||||
/bin/
|
||||
identity/
|
||||
event_initiator.identity.json
|
||||
event_initiator.key
|
||||
@@ -20,10 +20,25 @@ e2e/config.test.yaml
|
||||
logs/
|
||||
|
||||
# Built binaries
|
||||
lux-mpc
|
||||
lux-mpc-bridge
|
||||
lux-mpc-cli
|
||||
mpcd
|
||||
/lux-mpc
|
||||
/lux-mpc-bridge
|
||||
/lux-mpc-cli
|
||||
/mpcd
|
||||
*-linux-amd64
|
||||
*-linux-arm64
|
||||
*-darwin-amd64
|
||||
*-darwin-arm64
|
||||
|
||||
# Docker build artifacts
|
||||
.docker-build/
|
||||
|
||||
# Dashboard build
|
||||
dashboard/node_modules/
|
||||
dashboard/.next/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
|
||||
|
||||
|
||||
+9
-45
@@ -1,50 +1,14 @@
|
||||
# Build stage
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
FROM golang:1.23-alpine AS builder
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy go mod files
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /mpcd ./cmd/mpcd/
|
||||
|
||||
# Build the binaries (CGO_ENABLED=0 for static, GOFIPS140=off to avoid QEMU FIPS issue)
|
||||
RUN CGO_ENABLED=0 GOFIPS140=off go build -o mpcd ./cmd/mpcd
|
||||
RUN CGO_ENABLED=0 GOFIPS140=off go build -o lux-mpc-cli ./cmd/lux-mpc-cli
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl bash
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy binaries from builder
|
||||
COPY --from=builder /build/mpcd /usr/local/bin/mpcd
|
||||
COPY --from=builder /build/lux-mpc-cli /usr/local/bin/lux-mpc-cli
|
||||
|
||||
# Symlink for backward compatibility (StatefulSet runs /usr/local/bin/lux-mpc)
|
||||
RUN ln -s /usr/local/bin/mpcd /usr/local/bin/lux-mpc
|
||||
|
||||
# Copy config templates
|
||||
COPY config.yaml.template /app/
|
||||
COPY peers.json /app/
|
||||
|
||||
# Create data and log directories
|
||||
RUN mkdir -p /data/mpc /app/logs /app/identity
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 6000 8080 9090
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/health || exit 1
|
||||
|
||||
# Default command
|
||||
CMD ["mpcd", "start", "--config", "/app/config.yaml"]
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates
|
||||
COPY --from=builder /mpcd /usr/local/bin/mpcd
|
||||
COPY --from=builder /src/pkg/db/migrations /migrations
|
||||
EXPOSE 8081
|
||||
ENTRYPOINT ["mpcd"]
|
||||
|
||||
@@ -24,9 +24,11 @@ import (
|
||||
"github.com/urfave/cli/v3"
|
||||
"golang.org/x/term"
|
||||
|
||||
mpcapi "github.com/luxfi/mpc/pkg/api"
|
||||
"github.com/luxfi/mpc/pkg/backup"
|
||||
"github.com/luxfi/mpc/pkg/config"
|
||||
"github.com/luxfi/mpc/pkg/constant"
|
||||
"github.com/luxfi/mpc/pkg/db"
|
||||
"github.com/luxfi/mpc/pkg/event"
|
||||
"github.com/luxfi/mpc/pkg/eventconsumer"
|
||||
"github.com/luxfi/mpc/pkg/identity"
|
||||
@@ -105,6 +107,20 @@ func main() {
|
||||
Usage: "Log level (debug, info, warn, error)",
|
||||
Value: "info",
|
||||
},
|
||||
// Dashboard API flags
|
||||
&cli.StringFlag{
|
||||
Name: "api-db",
|
||||
Usage: "PostgreSQL connection URL for dashboard API",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "api-listen",
|
||||
Usage: "Dashboard API listen address",
|
||||
Value: ":8081",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "jwt-secret",
|
||||
Usage: "JWT signing secret for dashboard auth",
|
||||
},
|
||||
// Legacy mode flags
|
||||
&cli.BoolFlag{
|
||||
Name: "decrypt-private-key",
|
||||
@@ -878,6 +894,56 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Start Dashboard API server if PostgreSQL URL is provided
|
||||
apiDBURL := c.String("api-db")
|
||||
if apiDBURL == "" {
|
||||
apiDBURL = os.Getenv("MPC_API_DB")
|
||||
}
|
||||
if apiDBURL != "" {
|
||||
apiListenAddr := c.String("api-listen")
|
||||
jwtSecret := c.String("jwt-secret")
|
||||
if jwtSecret == "" {
|
||||
jwtSecret = os.Getenv("MPC_JWT_SECRET")
|
||||
}
|
||||
if jwtSecret == "" {
|
||||
jwtSecret = "change-me-in-production"
|
||||
logger.Warn("Using default JWT secret - NOT for production!")
|
||||
}
|
||||
|
||||
database, err := db.New(ctx, apiDBURL)
|
||||
if err != nil {
|
||||
logger.Error("Failed to connect to dashboard database", err)
|
||||
} else {
|
||||
defer database.Close()
|
||||
|
||||
if err := database.Migrate(ctx); err != nil {
|
||||
logger.Error("Failed to run database migrations", err)
|
||||
} else {
|
||||
mpcBackend := &ConsensusMPCBackend{
|
||||
pubSub: pubSub,
|
||||
peerRegistry: peerRegistry,
|
||||
factory: factory,
|
||||
nodeID: nodeID,
|
||||
threshold: int(threshold),
|
||||
}
|
||||
|
||||
apiServer := mpcapi.NewServer(database, mpcBackend, jwtSecret, apiListenAddr)
|
||||
apiServer.StartScheduler(ctx)
|
||||
|
||||
logger.Info("Dashboard API server starting", "addr", apiListenAddr)
|
||||
_, apiErrCh := apiServer.Start()
|
||||
go func() {
|
||||
if err := <-apiErrCh; err != nil {
|
||||
logger.Error("Dashboard API server failed", err)
|
||||
}
|
||||
}()
|
||||
defer apiServer.Shutdown(context.Background())
|
||||
|
||||
logger.Info("Dashboard API ready", "addr", apiListenAddr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for shutdown signal
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
@@ -887,6 +953,126 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConsensusMPCBackend implements api.MPCBackend using the consensus transport.
|
||||
type ConsensusMPCBackend struct {
|
||||
pubSub *ConsensusPubSubAdapter
|
||||
peerRegistry *ConsensusPeerRegistry
|
||||
factory *transport.Factory
|
||||
nodeID string
|
||||
threshold int
|
||||
}
|
||||
|
||||
func (b *ConsensusMPCBackend) TriggerKeygen(walletID string) (*mpcapi.KeygenResult, error) {
|
||||
if walletID == "" {
|
||||
h := sha256.Sum256([]byte(fmt.Sprintf("%s-%d", b.nodeID, time.Now().UnixNano())))
|
||||
walletID = hex.EncodeToString(h[:16])
|
||||
}
|
||||
|
||||
resultTopic := fmt.Sprintf("mpc.mpc_keygen_result.%s", walletID)
|
||||
resultCh := make(chan *event.KeygenResultEvent, 1)
|
||||
unsub, err := b.pubSub.Subscribe(resultTopic, func(natMsg *nats.Msg) {
|
||||
var result event.KeygenResultEvent
|
||||
if err := json.Unmarshal(natMsg.Data, &result); err == nil {
|
||||
select {
|
||||
case resultCh <- &result:
|
||||
default:
|
||||
}
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to subscribe to result topic: %w", err)
|
||||
}
|
||||
defer unsub.Unsubscribe()
|
||||
|
||||
msg := types.GenerateKeyMessage{WalletID: walletID}
|
||||
msgData, _ := json.Marshal(msg)
|
||||
if err := b.pubSub.Publish("mpc:generate", msgData); err != nil {
|
||||
return nil, fmt.Errorf("failed to publish keygen: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case result := <-resultCh:
|
||||
if result.ResultType != event.ResultTypeSuccess {
|
||||
return nil, fmt.Errorf("keygen failed: %s", result.ErrorReason)
|
||||
}
|
||||
ethAddr := ""
|
||||
if len(result.ECDSAPubKey) >= 33 {
|
||||
ethAddr = pubKeyToEthAddress(result.ECDSAPubKey)
|
||||
}
|
||||
return &mpcapi.KeygenResult{
|
||||
WalletID: result.WalletID,
|
||||
ECDSAPubKey: hex.EncodeToString(result.ECDSAPubKey),
|
||||
EDDSAPubKey: hex.EncodeToString(result.EDDSAPubKey),
|
||||
EthAddress: ethAddr,
|
||||
}, nil
|
||||
case <-time.After(120 * time.Second):
|
||||
return nil, fmt.Errorf("keygen timed out after 120s")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ConsensusMPCBackend) TriggerSign(walletID string, payload []byte) (*mpcapi.SignResult, error) {
|
||||
txID := fmt.Sprintf("sign-%d", time.Now().UnixNano())
|
||||
resultTopic := fmt.Sprintf("mpc.mpc_signing_result.%s", txID)
|
||||
resultCh := make(chan json.RawMessage, 1)
|
||||
unsub, err := b.pubSub.Subscribe(resultTopic, func(natMsg *nats.Msg) {
|
||||
select {
|
||||
case resultCh <- natMsg.Data:
|
||||
default:
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to subscribe to signing result: %w", err)
|
||||
}
|
||||
defer unsub.Unsubscribe()
|
||||
|
||||
msg := types.SignTxMessage{
|
||||
WalletID: walletID,
|
||||
TxID: txID,
|
||||
Tx: payload,
|
||||
}
|
||||
msgData, _ := json.Marshal(msg)
|
||||
if err := b.pubSub.Publish("mpc:sign", msgData); err != nil {
|
||||
return nil, fmt.Errorf("failed to publish sign request: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case data := <-resultCh:
|
||||
var result struct {
|
||||
R string `json:"r"`
|
||||
S string `json:"s"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
json.Unmarshal(data, &result)
|
||||
return &mpcapi.SignResult{R: result.R, S: result.S, Signature: result.Signature}, nil
|
||||
case <-time.After(60 * time.Second):
|
||||
return nil, fmt.Errorf("signing timed out after 60s")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ConsensusMPCBackend) TriggerReshare(walletID string, newThreshold int, newParticipants []string) error {
|
||||
msg := map[string]interface{}{
|
||||
"wallet_id": walletID,
|
||||
"new_threshold": newThreshold,
|
||||
"new_participants": newParticipants,
|
||||
}
|
||||
msgData, _ := json.Marshal(msg)
|
||||
return b.pubSub.Publish("mpc:reshare", msgData)
|
||||
}
|
||||
|
||||
func (b *ConsensusMPCBackend) GetClusterStatus() *mpcapi.ClusterStatus {
|
||||
ready := b.peerRegistry.ArePeersReady()
|
||||
connected := b.factory.Transport().GetPeers()
|
||||
return &mpcapi.ClusterStatus{
|
||||
NodeID: b.nodeID,
|
||||
Mode: "consensus",
|
||||
ExpectedPeers: len(b.peerRegistry.peerIDs),
|
||||
ConnectedPeers: len(connected),
|
||||
Ready: ready,
|
||||
Threshold: b.threshold,
|
||||
Version: Version,
|
||||
}
|
||||
}
|
||||
|
||||
// loadOrGenerateIdentity loads or generates Ed25519 identity
|
||||
func loadOrGenerateIdentity(keysDir, nodeID string) (ed25519.PrivateKey, ed25519.PublicKey, error) {
|
||||
identityPath := filepath.Join(keysDir, nodeID+"_identity.json")
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: mpc_api
|
||||
POSTGRES_USER: mpc
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mpc -d mpc_api"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
command: >
|
||||
start --mode consensus
|
||||
--api-db postgres://mpc:${POSTGRES_PASSWORD:-changeme}@postgres:5432/mpc_api?sslmode=disable
|
||||
--api-listen :8081
|
||||
--jwt-secret ${JWT_SECRET:-dev-jwt-secret-change-in-production}
|
||||
ports:
|
||||
- "8081:8081"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-changeme}
|
||||
|
||||
dashboard:
|
||||
build:
|
||||
context: ./dashboard
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:8081/api/v1
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,4 @@
|
||||
.git
|
||||
node_modules
|
||||
.next
|
||||
.env*
|
||||
@@ -0,0 +1,2 @@
|
||||
# MPC API URL
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8081/api/v1
|
||||
@@ -0,0 +1,27 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml* package-lock.json* ./
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate || true
|
||||
RUN if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
else npm install; fi
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run 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"]
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { api } from '@/lib/api'
|
||||
import type { AuditEntry } from '@/lib/types'
|
||||
|
||||
export default function AuditPage() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.listAudit().then(setEntries).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Audit Log</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Track all actions performed in the system.</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Action</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Resource</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">User</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Date</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3 font-medium">{entry.action}</td>
|
||||
<td className="px-4 py-3">
|
||||
{entry.resource_type && (
|
||||
<span className="text-muted-foreground">
|
||||
{entry.resource_type}
|
||||
{entry.resource_id && (
|
||||
<span className="ml-1 font-mono text-xs">{entry.resource_id.slice(0, 12)}...</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{!entry.resource_type && <span className="text-muted-foreground">--</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||
{entry.user_id || '--'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{new Date(entry.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||
{entry.ip_address || '--'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{entries.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No audit entries.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styles */
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted)) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--muted));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.scrollbar-none {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { GeistSans } from 'geist/font/sans'
|
||||
import { GeistMono } from 'geist/font/mono'
|
||||
import { Providers } from './providers'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Lux MPC',
|
||||
description: 'MPC Wallet Management Dashboard',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`dark ${GeistSans.variable} ${GeistMono.variable}`}>
|
||||
<body className="font-sans min-h-screen bg-background text-foreground antialiased">
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { api, APIError } from '@/lib/api'
|
||||
import { setTokens } from '@/lib/auth'
|
||||
import type { AuthResponse } from '@/lib/types'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [mfaCode, setMfaCode] = useState('')
|
||||
const [showMfa, setShowMfa] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const data = await api.login({ email, password, mfa_code: mfaCode || undefined })
|
||||
|
||||
if ('mfa_required' in data) {
|
||||
setShowMfa(true)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const auth = data as AuthResponse
|
||||
setTokens(auth.access_token, auth.refresh_token)
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof APIError ? err.message : 'Login failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="rounded-lg border border-border bg-card p-8">
|
||||
<h1 className="mb-6 text-center text-xl font-semibold tracking-tight">
|
||||
Lux MPC
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-muted-foreground">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-muted-foreground">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showMfa && (
|
||||
<div>
|
||||
<label htmlFor="mfa" className="mb-1.5 block text-sm font-medium text-muted-foreground">
|
||||
MFA Code
|
||||
</label>
|
||||
<input
|
||||
id="mfa"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
value={mfaCode}
|
||||
onChange={(e) => setMfaCode(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono tracking-widest ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" className="text-foreground underline underline-offset-4 hover:text-foreground/80">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { StatusBadge } from '@/components/common/status-badge'
|
||||
import { ChainIcon } from '@/components/common/chain-icon'
|
||||
|
||||
const stats = [
|
||||
{ label: 'Vaults', value: '--', description: 'Total vaults' },
|
||||
{ label: 'Wallets', value: '--', description: 'Total wallets' },
|
||||
{ label: 'Pending TX', value: '--', description: 'Awaiting approval' },
|
||||
]
|
||||
|
||||
const recentTx: {
|
||||
id: string
|
||||
type: string
|
||||
chain: string
|
||||
amount: string
|
||||
status: string
|
||||
created_at: string
|
||||
}[] = []
|
||||
|
||||
const clusterNodes = [
|
||||
{ id: 'node0', status: 'running', uptime: '99.9%' },
|
||||
{ id: 'node1', status: 'running', uptime: '99.8%' },
|
||||
{ id: 'node2', status: 'running', uptime: '99.7%' },
|
||||
]
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{/* Portfolio overview */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Portfolio overview and MPC cluster status.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Total value */}
|
||||
<div className="mb-8 rounded-lg border border-border bg-card p-6">
|
||||
<p className="text-sm text-muted-foreground">Total Portfolio Value</p>
|
||||
<p className="mt-1 text-3xl font-semibold tracking-tight font-mono">$--,---.--</p>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-3">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="rounded-lg border border-border bg-card p-6">
|
||||
<p className="text-sm text-muted-foreground">{stat.label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold tracking-tight font-mono">{stat.value}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{stat.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent transactions */}
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 text-lg font-semibold">Recent Transactions</h2>
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Type</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Amount</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentTx.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No recent transactions.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
recentTx.map((tx) => (
|
||||
<tr key={tx.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3 capitalize">{tx.type}</td>
|
||||
<td className="px-4 py-3">
|
||||
<ChainIcon chain={tx.chain} />
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono">{tx.amount}</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={tx.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{tx.created_at}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vault summary */}
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 text-lg font-semibold">Vault Summary</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-center text-muted-foreground">
|
||||
No vaults created yet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MPC Cluster Status */}
|
||||
<div>
|
||||
<h2 className="mb-4 text-lg font-semibold">MPC Cluster Status</h2>
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Threshold</p>
|
||||
<p className="text-lg font-semibold font-mono">2 of 3</p>
|
||||
</div>
|
||||
<StatusBadge status="running" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{clusterNodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="flex items-center justify-between rounded-md bg-muted/50 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
<span className="text-sm font-medium font-mono">{node.id}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs text-muted-foreground">Uptime: {node.uptime}</span>
|
||||
<StatusBadge status={node.status} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const chains = ['ethereum', 'bitcoin', 'lux', 'solana', 'xrpl']
|
||||
|
||||
export default function NewPaymentPage() {
|
||||
const [merchantName, setMerchantName] = useState('')
|
||||
const [recipientAddress, setRecipientAddress] = useState('')
|
||||
const [chain, setChain] = useState('ethereum')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [memo, setMemo] = useState('')
|
||||
const [expiresInHours, setExpiresInHours] = useState(24)
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [paymentUrl, setPaymentUrl] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await api.createPaymentRequest({
|
||||
merchant_name: merchantName || undefined,
|
||||
recipient_address: recipientAddress,
|
||||
chain,
|
||||
amount,
|
||||
memo: memo || undefined,
|
||||
expires_in_hours: expiresInHours,
|
||||
})
|
||||
setPaymentUrl(result.payment_url)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to create payment request')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
if (paymentUrl) {
|
||||
navigator.clipboard.writeText(paymentUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
if (paymentUrl) {
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-lg space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Payment Request Created</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Share this link with the payer.</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-6 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Payment Link</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={paymentUrl}
|
||||
className="flex-1 rounded-md border border-input bg-background px-3 py-2 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Amount</dt>
|
||||
<dd className="mt-1 font-mono">{amount}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Chain</dt>
|
||||
<dd className="mt-1 capitalize">{chain}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Expires</dt>
|
||||
<dd className="mt-1">{expiresInHours}h</dd>
|
||||
</div>
|
||||
{merchantName && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Merchant</dt>
|
||||
<dd className="mt-1">{merchantName}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setPaymentUrl(null)
|
||||
setMerchantName('')
|
||||
setRecipientAddress('')
|
||||
setAmount('')
|
||||
setMemo('')
|
||||
}}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
Create Another
|
||||
</button>
|
||||
<Link
|
||||
href="/payments"
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
Back to Payments
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href="/payments" className="hover:text-foreground">Payments</Link>
|
||||
<span>/</span>
|
||||
<span>New</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mb-8 text-2xl font-semibold tracking-tight">Create Payment Request</h1>
|
||||
|
||||
<div className="mx-auto max-w-lg">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Merchant Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={merchantName}
|
||||
onChange={(e) => setMerchantName(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Your business name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Recipient Address</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={recipientAddress}
|
||||
onChange={(e) => setRecipientAddress(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="0x..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Chain</label>
|
||||
<select
|
||||
value={chain}
|
||||
onChange={(e) => setChain(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{chains.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Amount</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="100.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Memo</label>
|
||||
<textarea
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Optional note for the payer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Expiry (hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={720}
|
||||
value={expiresInHours}
|
||||
onChange={(e) => setExpiresInHours(Number(e.target.value))}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !recipientAddress || !amount}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create Payment Request'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { StatusBadge } from '@/components/common/status-badge'
|
||||
import { api } from '@/lib/api'
|
||||
import type { PaymentRequest } from '@/lib/types'
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const [payments, setPayments] = useState<PaymentRequest[]>([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.listPaymentRequests().then(setPayments).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Payment Requests</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Create and manage payment requests.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/payments/new"
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Create Payment Request
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Merchant</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Amount</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payments.map((pr) => (
|
||||
<tr key={pr.id} className="border-b border-border last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 font-medium">{pr.merchant_name || '--'}</td>
|
||||
<td className="px-4 py-3 font-mono">{pr.amount} {pr.token || ''}</td>
|
||||
<td className="px-4 py-3 capitalize">{pr.chain}</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={pr.status} /></td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{new Date(pr.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{payments.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No payment requests.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, type FormEvent } from 'react'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { StatusBadge } from '@/components/common/status-badge'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Policy } from '@/lib/types'
|
||||
|
||||
const actionOptions = ['approve', 'deny', 'require_approval']
|
||||
|
||||
export default function PoliciesPage() {
|
||||
const [policies, setPolicies] = useState<Policy[]>([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Create form state
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
api.listPolicies().then(setPolicies).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setCreating(true)
|
||||
setError('')
|
||||
try {
|
||||
const policy = await api.createPolicy({
|
||||
name,
|
||||
action,
|
||||
priority,
|
||||
conditions: {},
|
||||
required_approvers: requiredApprovers,
|
||||
approver_roles: approverRoles.split(',').map((r) => r.trim()).filter(Boolean),
|
||||
})
|
||||
setPolicies((prev) => [policy, ...prev])
|
||||
setName('')
|
||||
setPriority(0)
|
||||
setRequiredApprovers(1)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to create policy')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await api.deletePolicy(id)
|
||||
setPolicies((prev) => prev.filter((p) => p.id !== id))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to delete policy')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(id: string, enabled: boolean) {
|
||||
try {
|
||||
const updated = await api.updatePolicy(id, { enabled: !enabled })
|
||||
setPolicies((prev) => prev.map((p) => (p.id === id ? updated : p)))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to update policy')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Policies</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Configure signing policies and approval rules.</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
|
||||
|
||||
{/* Inline create form */}
|
||||
<form onSubmit={handleCreate} className="mb-8 rounded-lg border border-border bg-card p-6 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Create Policy</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Daily spend limit"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Action</label>
|
||||
<select
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{actionOptions.map((a) => (
|
||||
<option key={a} value={a}>{a.replace(/_/g, ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Priority</label>
|
||||
<input
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(Number(e.target.value))}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Required Approvers</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={requiredApprovers}
|
||||
onChange={(e) => setRequiredApprovers(Number(e.target.value))}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Approver Roles (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={approverRoles}
|
||||
onChange={(e) => setApproverRoles(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="admin,owner"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !name.trim()}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Policy'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Action</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Priority</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{policies.map((p) => (
|
||||
<tr key={p.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3 font-medium">{p.name}</td>
|
||||
<td className="px-4 py-3 capitalize">{p.action.replace(/_/g, ' ')}</td>
|
||||
<td className="px-4 py-3 font-mono">{p.priority}</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={p.enabled ? 'enabled' : 'disabled'} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-3">
|
||||
<button
|
||||
onClick={() => handleToggle(p.id, p.enabled)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{p.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(p.id)}
|
||||
className="text-sm text-destructive hover:underline"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{policies.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No policies configured.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { api, APIError } from '@/lib/api'
|
||||
import { setTokens } from '@/lib/auth'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [orgName, setOrgName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const data = await api.register({ org_name: orgName, email, password })
|
||||
setTokens(data.access_token, data.refresh_token)
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof APIError ? err.message : 'Registration failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="rounded-lg border border-border bg-card p-8">
|
||||
<h1 className="mb-6 text-center text-xl font-semibold tracking-tight">
|
||||
Create Account
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="org" className="mb-1.5 block text-sm font-medium text-muted-foreground">
|
||||
Organization Name
|
||||
</label>
|
||||
<input
|
||||
id="org"
|
||||
type="text"
|
||||
required
|
||||
value={orgName}
|
||||
onChange={(e) => setOrgName(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Acme Inc."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-muted-foreground">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-muted-foreground">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Min 8 characters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="text-foreground underline underline-offset-4 hover:text-foreground/80">
|
||||
Login
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, type FormEvent } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { api } from '@/lib/api'
|
||||
import type { APIKey, APIKeyCreateResponse } from '@/lib/types'
|
||||
|
||||
const permissionOptions = ['read', 'write', 'sign', 'admin']
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const [keys, setKeys] = useState<APIKey[]>([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Create form
|
||||
const [name, setName] = useState('')
|
||||
const [permissions, setPermissions] = useState<string[]>(['read'])
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newKey, setNewKey] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.listAPIKeys().then(setKeys).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
function togglePermission(perm: string) {
|
||||
setPermissions((prev) =>
|
||||
prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
setCreating(true)
|
||||
setError('')
|
||||
try {
|
||||
const result: APIKeyCreateResponse = await api.createAPIKey({ name, permissions })
|
||||
setNewKey(result.key)
|
||||
setKeys((prev) => [result, ...prev])
|
||||
setName('')
|
||||
setPermissions(['read'])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to create key')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
if (newKey) {
|
||||
navigator.clipboard.writeText(newKey)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await api.deleteAPIKey(id)
|
||||
setKeys((prev) => prev.filter((k) => k.id !== id))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to delete key')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href="/settings" className="hover:text-foreground">Settings</Link>
|
||||
<span>/</span>
|
||||
<span>API Keys</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">API Keys</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Manage programmatic access keys.</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
|
||||
|
||||
{/* Created key banner */}
|
||||
{newKey && (
|
||||
<div className="mb-6 rounded-lg border border-amber-500/20 bg-amber-500/5 p-4">
|
||||
<p className="mb-2 text-sm font-medium text-amber-400">
|
||||
API key created. Copy it now -- it will not be shown again.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 break-all rounded-md bg-muted p-2 font-mono text-xs">{newKey}</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 rounded-md border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-accent"
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNewKey(null)}
|
||||
className="mt-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create form */}
|
||||
<form onSubmit={handleCreate} className="mb-8 rounded-lg border border-border bg-card p-6 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Create Key</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Production API key"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Permissions</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{permissionOptions.map((perm) => (
|
||||
<label key={perm} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permissions.includes(perm)}
|
||||
onChange={() => togglePermission(perm)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span className="text-sm capitalize">{perm}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !name.trim()}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Key'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Key Prefix</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Created</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Last Used</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((key) => (
|
||||
<tr key={key.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3 font-medium">{key.name}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{key.key_prefix}...</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{new Date(key.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(key.id)}
|
||||
className="text-sm text-destructive hover:underline"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{keys.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No API keys created.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { api } from '@/lib/api'
|
||||
import type { InfoResponse } from '@/lib/types'
|
||||
|
||||
const settingsLinks = [
|
||||
{
|
||||
href: '/settings/api-keys' as const,
|
||||
title: 'API Keys',
|
||||
description: 'Manage API keys for programmatic access.',
|
||||
},
|
||||
{
|
||||
href: '/settings/webhooks' as const,
|
||||
title: 'Webhooks',
|
||||
description: 'Configure webhook endpoints for event notifications.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [info, setInfo] = useState<InfoResponse | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.getInfo().then(setInfo).catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Organization settings and integrations.</p>
|
||||
</div>
|
||||
|
||||
{/* Org info */}
|
||||
<div className="mb-8 rounded-lg border border-border bg-card p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold">Organization</h2>
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Service</dt>
|
||||
<dd className="mt-1 text-sm font-medium">{info?.name || '--'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Version</dt>
|
||||
<dd className="mt-1 font-mono text-sm">{info?.version || '--'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Supported Chains</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{info?.supported_chains?.join(', ') || '--'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Key Types</dt>
|
||||
<dd className="mt-1 font-mono text-sm">
|
||||
{info?.key_types?.join(', ') || '--'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Protocols</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{info?.protocols?.join(', ') || '--'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Sub-pages */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{settingsLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="group rounded-lg border border-border bg-card p-6 transition-colors hover:border-foreground/20"
|
||||
>
|
||||
<h3 className="font-semibold group-hover:text-foreground">{link.title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{link.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, type FormEvent } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { StatusBadge } from '@/components/common/status-badge'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Webhook } from '@/lib/types'
|
||||
|
||||
const eventOptions = [
|
||||
'transaction.created',
|
||||
'transaction.approved',
|
||||
'transaction.signed',
|
||||
'transaction.broadcast',
|
||||
'transaction.failed',
|
||||
'wallet.created',
|
||||
'wallet.reshared',
|
||||
'policy.triggered',
|
||||
]
|
||||
|
||||
export default function WebhooksPage() {
|
||||
const [webhooks, setWebhooks] = useState<Webhook[]>([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Create form
|
||||
const [url, setUrl] = useState('')
|
||||
const [secret, setSecret] = useState('')
|
||||
const [selectedEvents, setSelectedEvents] = useState<string[]>([])
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [testing, setTesting] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.listWebhooks().then(setWebhooks).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
function toggleEvent(event: string) {
|
||||
setSelectedEvents((prev) =>
|
||||
prev.includes(event) ? prev.filter((e) => e !== event) : [...prev, event]
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!url.trim() || selectedEvents.length === 0 || !secret.trim()) return
|
||||
setCreating(true)
|
||||
setError('')
|
||||
try {
|
||||
const webhook = await api.createWebhook({ url, events: selectedEvents, secret })
|
||||
setWebhooks((prev) => [webhook, ...prev])
|
||||
setUrl('')
|
||||
setSecret('')
|
||||
setSelectedEvents([])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to create webhook')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(id: string) {
|
||||
setTesting(id)
|
||||
setError('')
|
||||
try {
|
||||
await api.testWebhook(id)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Test failed')
|
||||
} finally {
|
||||
setTesting(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await api.deleteWebhook(id)
|
||||
setWebhooks((prev) => prev.filter((w) => w.id !== id))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to delete webhook')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(id: string, enabled: boolean) {
|
||||
try {
|
||||
const updated = await api.updateWebhook(id, { enabled: !enabled })
|
||||
setWebhooks((prev) => prev.map((w) => (w.id === id ? updated : w)))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to update webhook')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href="/settings" className="hover:text-foreground">Settings</Link>
|
||||
<span>/</span>
|
||||
<span>Webhooks</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Webhooks</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Receive event notifications via HTTP callbacks.</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
|
||||
|
||||
{/* Create form */}
|
||||
<form onSubmit={handleCreate} className="mb-8 rounded-lg border border-border bg-card p-6 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Create Webhook</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="https://api.example.com/webhook"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Secret</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="whsec_..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Events</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{eventOptions.map((event) => (
|
||||
<label key={event} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEvents.includes(event)}
|
||||
onChange={() => toggleEvent(event)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span className="text-sm">{event}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !url.trim() || selectedEvents.length === 0 || !secret.trim()}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Webhook'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">URL</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Events</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{webhooks.map((wh) => (
|
||||
<tr key={wh.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3 font-mono text-xs break-all max-w-[300px]">{wh.url}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{wh.events.map((ev) => (
|
||||
<span key={ev} className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
{ev}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={wh.enabled ? 'active' : 'disabled'} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-3">
|
||||
<button
|
||||
onClick={() => handleTest(wh.id)}
|
||||
disabled={testing === wh.id}
|
||||
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{testing === wh.id ? 'Testing...' : 'Test'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggle(wh.id, wh.enabled)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{wh.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(wh.id)}
|
||||
className="text-sm text-destructive hover:underline"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{webhooks.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No webhooks configured.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { SafeTxQueue } from '@/components/smart-wallets/safe-tx-queue'
|
||||
import { TxTable } from '@/components/transactions/tx-table'
|
||||
|
||||
interface SmartWalletDetail {
|
||||
id: string
|
||||
type: 'safe' | 'erc4337'
|
||||
chain: string
|
||||
chainId: number
|
||||
contractAddress: string
|
||||
threshold: number
|
||||
owners: string[]
|
||||
mpcWalletId: string
|
||||
}
|
||||
|
||||
interface PendingSafeTx {
|
||||
id: string
|
||||
to: string
|
||||
value: string
|
||||
data: string
|
||||
nonce: number
|
||||
confirmations: number
|
||||
required: number
|
||||
submittedBy: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export default function SmartWalletDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
|
||||
// TODO: fetch from API
|
||||
const [wallet] = useState<SmartWalletDetail>({
|
||||
id: params.id,
|
||||
type: 'safe',
|
||||
chain: 'Ethereum',
|
||||
chainId: 1,
|
||||
contractAddress: '0x1234567890abcdef1234567890abcdef12345678',
|
||||
threshold: 2,
|
||||
owners: [
|
||||
'0xaaa1111111111111111111111111111111111111',
|
||||
'0xbbb2222222222222222222222222222222222222',
|
||||
'0xccc3333333333333333333333333333333333333',
|
||||
],
|
||||
mpcWalletId: 'wallet-abc',
|
||||
})
|
||||
|
||||
const [pendingTxs] = useState<PendingSafeTx[]>([
|
||||
{
|
||||
id: 'stx-1',
|
||||
to: '0xddd4444444444444444444444444444444444444',
|
||||
value: '1.5 ETH',
|
||||
data: '0x',
|
||||
nonce: 0,
|
||||
confirmations: 1,
|
||||
required: 2,
|
||||
submittedBy: '0xaaa...111',
|
||||
createdAt: '2026-02-28T10:00:00Z',
|
||||
},
|
||||
])
|
||||
|
||||
// Safe: propose new transaction
|
||||
const [proposeTo, setProposeTo] = useState('')
|
||||
const [proposeValue, setProposeValue] = useState('')
|
||||
const [proposeData, setProposeData] = useState('')
|
||||
|
||||
// ERC-4337: submit UserOperation
|
||||
const [userOpTarget, setUserOpTarget] = useState('')
|
||||
const [userOpValue, setUserOpValue] = useState('')
|
||||
const [userOpCalldata, setUserOpCalldata] = useState('')
|
||||
|
||||
async function handleProposeSafeTx() {
|
||||
// TODO: call API to propose Safe transaction
|
||||
alert(`Propose tx to ${proposeTo} for ${proposeValue}`)
|
||||
}
|
||||
|
||||
async function handleSubmitUserOp() {
|
||||
// TODO: call API to submit UserOperation
|
||||
alert(`Submit UserOp to ${userOpTarget} for ${userOpValue}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-8 px-4 py-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{wallet.type === 'safe' ? 'Safe' : 'ERC-4337'} Wallet
|
||||
</h1>
|
||||
<p className="mt-1 truncate font-mono text-sm text-muted-foreground">
|
||||
{wallet.contractAddress}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contract info */}
|
||||
<section className="rounded-lg border border-border bg-card p-6 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Contract Info
|
||||
</h2>
|
||||
<dl className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Type</dt>
|
||||
<dd className="mt-1">
|
||||
{wallet.type === 'safe' ? 'Gnosis Safe' : 'ERC-4337 Account'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Chain</dt>
|
||||
<dd className="mt-1">
|
||||
{wallet.chain} ({wallet.chainId})
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Threshold</dt>
|
||||
<dd className="mt-1 font-mono">
|
||||
{wallet.threshold}-of-{wallet.owners.length}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">MPC Wallet</dt>
|
||||
<dd className="mt-1 font-mono text-xs">{wallet.mpcWalletId}</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="text-muted-foreground">Owners</dt>
|
||||
<dd className="mt-1 space-y-1">
|
||||
{wallet.owners.map((o) => (
|
||||
<p key={o} className="truncate font-mono text-xs">
|
||||
{o}
|
||||
</p>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* Safe: pending transactions */}
|
||||
{wallet.type === 'safe' && (
|
||||
<>
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Pending Transactions</h2>
|
||||
<SafeTxQueue transactions={pendingTxs} walletId={wallet.id} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-6 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Propose Transaction
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={proposeTo}
|
||||
onChange={(e) => setProposeTo(e.target.value)}
|
||||
placeholder="Recipient address (0x...)"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={proposeValue}
|
||||
onChange={(e) => setProposeValue(e.target.value)}
|
||||
placeholder="Value (e.g. 1.0)"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<textarea
|
||||
value={proposeData}
|
||||
onChange={(e) => setProposeData(e.target.value)}
|
||||
placeholder="Calldata (0x... or leave empty)"
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={handleProposeSafeTx}
|
||||
disabled={!proposeTo}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Propose Transaction
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ERC-4337: UserOperation */}
|
||||
{wallet.type === 'erc4337' && (
|
||||
<section className="rounded-lg border border-border bg-card p-6 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Submit UserOperation
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={userOpTarget}
|
||||
onChange={(e) => setUserOpTarget(e.target.value)}
|
||||
placeholder="Target address (0x...)"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={userOpValue}
|
||||
onChange={(e) => setUserOpValue(e.target.value)}
|
||||
placeholder="Value (e.g. 1.0)"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<textarea
|
||||
value={userOpCalldata}
|
||||
onChange={(e) => setUserOpCalldata(e.target.value)}
|
||||
placeholder="Calldata (0x...)"
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmitUserOp}
|
||||
disabled={!userOpTarget}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Submit UserOperation
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Transaction history */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Transaction History</h2>
|
||||
<TxTable
|
||||
transactions={[
|
||||
{
|
||||
id: 'tx-1',
|
||||
type: 'send',
|
||||
status: 'confirmed',
|
||||
chain: wallet.chain,
|
||||
from: wallet.contractAddress,
|
||||
to: '0xddd...444',
|
||||
value: '0.5 ETH',
|
||||
hash: '0xabc...123',
|
||||
timestamp: '2026-02-27T15:30:00Z',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { StatusBadge } from '@/components/common/status-badge'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Subscription } from '@/lib/types'
|
||||
|
||||
export default function SubscriptionDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [sub, setSub] = useState<Subscription | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [acting, setActing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
api.getSubscription(id).then(setSub).catch((e) => setError(e.message))
|
||||
}, [id])
|
||||
|
||||
async function handlePayNow() {
|
||||
setActing(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.payNow(id)
|
||||
const updated = await api.getSubscription(id)
|
||||
setSub(updated)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Payment failed')
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(status: string) {
|
||||
setActing(true)
|
||||
setError('')
|
||||
try {
|
||||
const updated = await api.updateSubscription(id, { status })
|
||||
setSub(updated)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Update failed')
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
setActing(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.deleteSubscription(id)
|
||||
const updated = await api.getSubscription(id)
|
||||
setSub(updated)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Cancellation failed')
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href="/subscriptions" className="hover:text-foreground">Subscriptions</Link>
|
||||
<span>/</span>
|
||||
<span>{sub?.name || 'Detail'}</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
|
||||
{!sub && !error && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
|
||||
{sub && (
|
||||
<>
|
||||
<div className="mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{sub.name}</h1>
|
||||
{sub.provider_name && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{sub.provider_name}</p>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={sub.status} />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="mb-8 rounded-lg border border-border bg-card p-6">
|
||||
<h2 className="mb-4 text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Subscription Details
|
||||
</h2>
|
||||
<dl className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Amount</dt>
|
||||
<dd className="mt-1 font-mono">{sub.amount} {sub.token || sub.currency}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Interval</dt>
|
||||
<dd className="mt-1 capitalize">{sub.interval}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Chain</dt>
|
||||
<dd className="mt-1 capitalize">{sub.chain}</dd>
|
||||
</div>
|
||||
{sub.wallet_id && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Wallet</dt>
|
||||
<dd className="mt-1">
|
||||
<Link href={`/wallets/${sub.wallet_id}`} className="font-mono text-xs hover:underline">
|
||||
{sub.wallet_id.slice(0, 12)}...
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2">
|
||||
<dt className="text-muted-foreground">Recipient</dt>
|
||||
<dd className="mt-1 font-mono text-xs break-all">{sub.recipient_address}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Next Payment</dt>
|
||||
<dd className="mt-1">{new Date(sub.next_payment_at).toLocaleDateString()}</dd>
|
||||
</div>
|
||||
{sub.last_payment_at && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Last Payment</dt>
|
||||
<dd className="mt-1">{new Date(sub.last_payment_at).toLocaleDateString()}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Created</dt>
|
||||
<dd className="mt-1">{new Date(sub.created_at).toLocaleDateString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Require Balance</dt>
|
||||
<dd className="mt-1">{sub.require_balance ? 'Yes' : 'No'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Retries</dt>
|
||||
<dd className="mt-1">{sub.retry_count} / {sub.max_retries}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{sub.status === 'active' && (
|
||||
<>
|
||||
<button
|
||||
onClick={handlePayNow}
|
||||
disabled={acting}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{acting ? 'Processing...' : 'Pay Now'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStatusChange('paused')}
|
||||
disabled={acting}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{sub.status === 'paused' && (
|
||||
<button
|
||||
onClick={() => handleStatusChange('active')}
|
||||
disabled={acting}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
)}
|
||||
{sub.status !== 'cancelled' && (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={acting}
|
||||
className="rounded-md border border-destructive/30 px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
>
|
||||
Cancel Subscription
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, type FormEvent } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Wallet, Vault } from '@/lib/types'
|
||||
|
||||
const chains = ['ethereum', 'bitcoin', 'lux', 'solana', 'xrpl']
|
||||
const intervals = ['daily', 'weekly', 'monthly', 'yearly']
|
||||
|
||||
export default function NewSubscriptionPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const [wallets, setWallets] = useState<Wallet[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [walletId, setWalletId] = useState('')
|
||||
const [recipientAddress, setRecipientAddress] = useState('')
|
||||
const [chain, setChain] = useState('ethereum')
|
||||
const [token, setToken] = useState('')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [interval, setInterval] = useState('monthly')
|
||||
const [requireBalance, setRequireBalance] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.listVaults().then(async (vaults: Vault[]) => {
|
||||
const all: Wallet[] = []
|
||||
for (const v of vaults) {
|
||||
const ws = await api.listWallets(v.id)
|
||||
all.push(...ws)
|
||||
}
|
||||
setWallets(all)
|
||||
if (all.length > 0) setWalletId(all[0].id)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
const sub = await api.createSubscription({
|
||||
wallet_id: walletId,
|
||||
name,
|
||||
recipient_address: recipientAddress,
|
||||
chain,
|
||||
token: token || undefined,
|
||||
amount,
|
||||
interval,
|
||||
require_balance: requireBalance,
|
||||
})
|
||||
router.push(`/subscriptions/${sub.id}`)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to create subscription')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href="/subscriptions" className="hover:text-foreground">Subscriptions</Link>
|
||||
<span>/</span>
|
||||
<span>New</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mb-8 text-2xl font-semibold tracking-tight">Create Subscription</h1>
|
||||
|
||||
<div className="mx-auto max-w-lg">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Cloud Hosting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Wallet</label>
|
||||
<select
|
||||
required
|
||||
value={walletId}
|
||||
onChange={(e) => setWalletId(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">Select wallet...</option>
|
||||
{wallets.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name || w.wallet_id} ({w.key_type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Recipient Address</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={recipientAddress}
|
||||
onChange={(e) => setRecipientAddress(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="0x..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Chain</label>
|
||||
<select
|
||||
value={chain}
|
||||
onChange={(e) => setChain(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{chains.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Token (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="USDC"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Amount</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="100.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Interval</label>
|
||||
<select
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{intervals.map((i) => (
|
||||
<option key={i} value={i}>{i}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={requireBalance}
|
||||
onChange={(e) => setRequireBalance(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Require sufficient balance before payment</span>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !name || !walletId || !recipientAddress || !amount}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create Subscription'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { StatusBadge } from '@/components/common/status-badge'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Subscription } from '@/lib/types'
|
||||
|
||||
export default function SubscriptionsPage() {
|
||||
const router = useRouter()
|
||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.listSubscriptions().then(setSubscriptions).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Subscriptions</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Manage recurring payments.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/subscriptions/new"
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Create Subscription
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Recipient</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Amount</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Interval</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Next Payment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subscriptions.map((sub) => (
|
||||
<tr
|
||||
key={sub.id}
|
||||
onClick={() => router.push(`/subscriptions/${sub.id}`)}
|
||||
className="cursor-pointer border-b border-border last:border-0 hover:bg-muted/30"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium">{sub.name}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
{sub.recipient_address.slice(0, 10)}...{sub.recipient_address.slice(-6)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono">
|
||||
{sub.amount} {sub.token || sub.currency}
|
||||
</td>
|
||||
<td className="px-4 py-3 capitalize">{sub.interval}</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={sub.status} /></td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{new Date(sub.next_payment_at).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{subscriptions.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No subscriptions.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, type FormEvent } from 'react'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { api } from '@/lib/api'
|
||||
import type { User } from '@/lib/types'
|
||||
|
||||
const roleBadgeColors: Record<string, string> = {
|
||||
owner: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
admin: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
signer: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
viewer: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20',
|
||||
}
|
||||
|
||||
const roleOptions = ['admin', 'signer', 'viewer']
|
||||
|
||||
export default function TeamPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Invite form
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState('signer')
|
||||
const [password, setPassword] = useState('')
|
||||
const [inviting, setInviting] = useState(false)
|
||||
|
||||
// Inline edit
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editRole, setEditRole] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.listUsers().then(setUsers).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
async function handleInvite(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!email.trim() || !password.trim()) return
|
||||
setInviting(true)
|
||||
setError('')
|
||||
try {
|
||||
const user = await api.inviteUser({ email, role, password })
|
||||
setUsers((prev) => [...prev, user])
|
||||
setEmail('')
|
||||
setPassword('')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to invite user')
|
||||
} finally {
|
||||
setInviting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateRole(id: string) {
|
||||
setError('')
|
||||
try {
|
||||
const updated = await api.updateUser(id, { role: editRole })
|
||||
setUsers((prev) => prev.map((u) => (u.id === id ? updated : u)))
|
||||
setEditingId(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to update role')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await api.deleteUser(id)
|
||||
setUsers((prev) => prev.filter((u) => u.id !== id))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to remove user')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Team</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Manage team members and their roles.</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
|
||||
|
||||
{/* Invite form */}
|
||||
<form onSubmit={handleInvite} className="mb-8 rounded-lg border border-border bg-card p-6 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Invite User</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Role</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{roleOptions.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Initial Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Temporary password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inviting || !email.trim() || !password.trim()}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{inviting ? 'Inviting...' : 'Invite'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* User table */}
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Email</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Role</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Joined</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3 font-medium">{user.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
{editingId === user.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={editRole}
|
||||
onChange={(e) => setEditRole(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{roleOptions.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleUpdateRole(user.id)}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium capitalize ${
|
||||
roleBadgeColors[user.role] ?? 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20'
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-3">
|
||||
{editingId !== user.id && user.role !== 'owner' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setEditingId(user.id); setEditRole(user.role) }}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="text-sm text-destructive hover:underline"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No team members yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { StatusBadge } from '@/components/common/status-badge'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Transaction } from '@/lib/types'
|
||||
|
||||
export default function TransactionDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [tx, setTx] = useState<Transaction | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [acting, setActing] = useState(false)
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
api.getTransaction(id).then(setTx).catch((e) => setError(e.message))
|
||||
}, [id])
|
||||
|
||||
async function handleApprove() {
|
||||
setActing(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.approveTransaction(id)
|
||||
const updated = await api.getTransaction(id)
|
||||
setTx(updated)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Approval failed')
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
setActing(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.rejectTransaction(id, { reason: rejectReason || undefined })
|
||||
const updated = await api.getTransaction(id)
|
||||
setTx(updated)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Rejection failed')
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href="/transactions" className="hover:text-foreground">Transactions</Link>
|
||||
<span>/</span>
|
||||
<span className="font-mono">{id?.slice(0, 8)}...</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
|
||||
{!tx && !error && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
|
||||
{tx && (
|
||||
<>
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight capitalize">{tx.tx_type} Transaction</h1>
|
||||
<p className="mt-1 font-mono text-xs text-muted-foreground">{tx.id}</p>
|
||||
</div>
|
||||
<StatusBadge status={tx.status} />
|
||||
</div>
|
||||
|
||||
{/* Transaction Info */}
|
||||
<div className="mb-8 rounded-lg border border-border bg-card p-6">
|
||||
<h2 className="mb-4 text-sm font-medium uppercase tracking-wider text-muted-foreground">Details</h2>
|
||||
<dl className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Type</dt>
|
||||
<dd className="mt-1 capitalize">{tx.tx_type}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Chain</dt>
|
||||
<dd className="mt-1 capitalize">{tx.chain}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Amount</dt>
|
||||
<dd className="mt-1 font-mono">{tx.amount || '--'}</dd>
|
||||
</div>
|
||||
{tx.token && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Token</dt>
|
||||
<dd className="mt-1 font-mono text-xs">{tx.token}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2">
|
||||
<dt className="text-muted-foreground">To Address</dt>
|
||||
<dd className="mt-1 font-mono text-xs break-all">{tx.to_address || '--'}</dd>
|
||||
</div>
|
||||
{tx.wallet_id && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Wallet</dt>
|
||||
<dd className="mt-1">
|
||||
<Link href={`/wallets/${tx.wallet_id}`} className="font-mono text-xs hover:underline">
|
||||
{tx.wallet_id.slice(0, 12)}...
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{tx.initiated_by && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Initiated By</dt>
|
||||
<dd className="mt-1 font-mono text-xs">{tx.initiated_by}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Created</dt>
|
||||
<dd className="mt-1">{new Date(tx.created_at).toLocaleString()}</dd>
|
||||
</div>
|
||||
{tx.signed_at && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Signed</dt>
|
||||
<dd className="mt-1">{new Date(tx.signed_at).toLocaleString()}</dd>
|
||||
</div>
|
||||
)}
|
||||
{tx.broadcast_at && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Broadcast</dt>
|
||||
<dd className="mt-1">{new Date(tx.broadcast_at).toLocaleString()}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* TX Hash */}
|
||||
{tx.tx_hash && (
|
||||
<div className="mb-8 rounded-lg border border-border bg-card p-6">
|
||||
<h2 className="mb-4 text-sm font-medium uppercase tracking-wider text-muted-foreground">Transaction Hash</h2>
|
||||
<p className="font-mono text-xs break-all">{tx.tx_hash}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signature */}
|
||||
{(tx.signature_r || tx.signature_s || tx.signature_eddsa) && (
|
||||
<div className="mb-8 rounded-lg border border-border bg-card p-6">
|
||||
<h2 className="mb-4 text-sm font-medium uppercase tracking-wider text-muted-foreground">Signature</h2>
|
||||
<dl className="space-y-3 text-sm">
|
||||
{tx.signature_r && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">R</dt>
|
||||
<dd className="mt-1 font-mono text-xs break-all">{tx.signature_r}</dd>
|
||||
</div>
|
||||
)}
|
||||
{tx.signature_s && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">S</dt>
|
||||
<dd className="mt-1 font-mono text-xs break-all">{tx.signature_s}</dd>
|
||||
</div>
|
||||
)}
|
||||
{tx.signature_eddsa && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">EdDSA</dt>
|
||||
<dd className="mt-1 font-mono text-xs break-all">{tx.signature_eddsa}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approvals / Rejection */}
|
||||
{tx.approved_by && tx.approved_by.length > 0 && (
|
||||
<div className="mb-8 rounded-lg border border-border bg-card p-6">
|
||||
<h2 className="mb-4 text-sm font-medium uppercase tracking-wider text-muted-foreground">Approved By</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tx.approved_by.map((u) => (
|
||||
<span key={u} className="rounded-md border border-border bg-muted/50 px-2 py-1 font-mono text-xs">{u}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tx.rejected_by && (
|
||||
<div className="mb-8 rounded-lg border border-destructive/30 bg-destructive/5 p-6">
|
||||
<h2 className="mb-2 text-sm font-medium text-destructive">Rejected</h2>
|
||||
<p className="text-sm">By: <span className="font-mono text-xs">{tx.rejected_by}</span></p>
|
||||
{tx.rejection_reason && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">Reason: {tx.rejection_reason}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{tx.status === 'pending_approval' && (
|
||||
<div className="rounded-lg border border-border bg-card p-6 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Actions</h2>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={acting}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{acting ? 'Processing...' : 'Approve'}
|
||||
</button>
|
||||
<div className="flex items-end gap-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-muted-foreground">Reason (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Rejection reason"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReject}
|
||||
disabled={acting}
|
||||
className="rounded-md border border-destructive/30 px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { StatusBadge } from '@/components/common/status-badge'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Transaction } from '@/lib/types'
|
||||
|
||||
const statusOptions = ['all', 'pending', 'pending_approval', 'approved', 'signing', 'signed', 'broadcast', 'failed', 'rejected']
|
||||
const chainOptions = ['all', 'ethereum', 'bitcoin', 'lux', 'solana', 'xrpl']
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const router = useRouter()
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [chainFilter, setChainFilter] = useState('all')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const filters: { status?: string; chain?: string } = {}
|
||||
if (statusFilter !== 'all') filters.status = statusFilter
|
||||
if (chainFilter !== 'all') filters.chain = chainFilter
|
||||
api.listTransactions(filters).then(setTransactions).catch((e) => setError(e.message))
|
||||
}, [statusFilter, chainFilter])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Transactions</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">View and manage all transactions.</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{statusOptions.map((s) => (
|
||||
<option key={s} value={s}>{s === 'all' ? 'All Status' : s.replace(/_/g, ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={chainFilter}
|
||||
onChange={(e) => setChainFilter(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{chainOptions.map((c) => (
|
||||
<option key={c} value={c}>{c === 'all' ? 'All Chains' : c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Type</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">To</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Amount</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.map((tx) => (
|
||||
<tr
|
||||
key={tx.id}
|
||||
onClick={() => router.push(`/transactions/${tx.id}`)}
|
||||
className="cursor-pointer border-b border-border last:border-0 hover:bg-muted/30"
|
||||
>
|
||||
<td className="px-4 py-3 capitalize">{tx.tx_type}</td>
|
||||
<td className="px-4 py-3 capitalize">{tx.chain}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
{tx.to_address ? `${tx.to_address.slice(0, 10)}...${tx.to_address.slice(-6)}` : '--'}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono">{tx.amount || '--'}</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={tx.status} /></td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{new Date(tx.created_at).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{transactions.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No transactions found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { DataTable, type Column } from '@/components/common/data-table'
|
||||
import { StatusBadge } from '@/components/common/status-badge'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Vault, Wallet } from '@/lib/types'
|
||||
|
||||
const columns: Column<Wallet>[] = [
|
||||
{ key: 'name', header: 'Name', render: (row) => <span>{row.name || row.wallet_id.slice(0, 12)}</span> },
|
||||
{ key: 'key_type', header: 'Type', render: (row) => <span className="font-mono text-xs">{row.key_type}</span> },
|
||||
{
|
||||
key: 'eth_address',
|
||||
header: 'Address',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.eth_address ? `${row.eth_address.slice(0, 10)}...${row.eth_address.slice(-8)}` : '--'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (row) => <StatusBadge status={row.status} />,
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'Created',
|
||||
render: (row) => <span className="text-muted-foreground">{new Date(row.created_at).toLocaleDateString()}</span>,
|
||||
},
|
||||
]
|
||||
|
||||
export default function VaultDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const [vault, setVault] = useState<Vault | null>(null)
|
||||
const [wallets, setWallets] = useState<Wallet[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!params.id) return
|
||||
api.getVault(params.id).then(setVault).catch(console.error)
|
||||
api.listWallets(params.id).then(setWallets).catch(console.error)
|
||||
}, [params.id])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{/* Vault header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||
<Link href="/vaults" className="hover:text-foreground">Vaults</Link>
|
||||
<span>/</span>
|
||||
<span>{vault?.name ?? 'Loading...'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{vault?.name ?? 'Loading...'}</h1>
|
||||
{vault?.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{vault.description}</p>
|
||||
)}
|
||||
{vault?.app_id && (
|
||||
<p className="mt-1 text-xs text-muted-foreground font-mono">
|
||||
App ID: {vault.app_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/policies?vault=${params.id}`}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
Policies
|
||||
</Link>
|
||||
<Link
|
||||
href={`/vaults/${params.id}/wallets/new`}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Create Wallet
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wallets table */}
|
||||
<h2 className="mb-4 text-lg font-semibold">Wallets</h2>
|
||||
<DataTable<Wallet>
|
||||
columns={columns}
|
||||
data={wallets}
|
||||
keyField="id"
|
||||
emptyMessage="No wallets in this vault yet."
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
|
||||
type KeyType = 'secp256k1' | 'ed25519'
|
||||
type Step = 'type' | 'name' | 'generate' | 'result'
|
||||
|
||||
interface KeygenResult {
|
||||
wallet_id: string
|
||||
eth_address: string
|
||||
btc_address: string
|
||||
sol_address: string
|
||||
public_key: string
|
||||
}
|
||||
|
||||
export default function NewWalletPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
|
||||
const [step, setStep] = useState<Step>('type')
|
||||
const [keyType, setKeyType] = useState<KeyType>('secp256k1')
|
||||
const [walletName, setWalletName] = useState('')
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [result, setResult] = useState<KeygenResult | null>(null)
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!walletName.trim()) return
|
||||
setGenerating(true)
|
||||
setError('')
|
||||
setStep('generate')
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/vaults/${params.id}/wallets`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
|
||||
},
|
||||
body: JSON.stringify({ name: walletName, key_type: keyType }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Key generation failed')
|
||||
}
|
||||
|
||||
setResult(data)
|
||||
setStep('result')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Key generation failed')
|
||||
setStep('name')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href="/vaults" className="hover:text-foreground">Vaults</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/vaults/${params.id}`} className="hover:text-foreground">Vault</Link>
|
||||
<span>/</span>
|
||||
<span>New Wallet</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mb-8 text-2xl font-semibold tracking-tight">Generate New Wallet</h1>
|
||||
|
||||
<div className="mx-auto max-w-lg">
|
||||
{/* Step indicator */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
{(['type', 'name', 'generate', 'result'] as Step[]).map((s, i) => (
|
||||
<div key={s} className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold ${
|
||||
step === s
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: i < ['type', 'name', 'generate', 'result'].indexOf(step)
|
||||
? 'bg-emerald-500/20 text-emerald-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
{i < 3 && <div className="h-px w-12 bg-border sm:w-16" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step: Choose key type */}
|
||||
{step === 'type' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Choose Key Type</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{(['secp256k1', 'ed25519'] as KeyType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setKeyType(t)}
|
||||
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||
keyType === t
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-foreground/20'
|
||||
}`}
|
||||
>
|
||||
<p className="font-semibold font-mono text-sm">{t}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t === 'secp256k1'
|
||||
? 'Bitcoin, Ethereum, Lux, XRPL'
|
||||
: 'Solana, TON, Polkadot'}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setStep('name')}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Name wallet */}
|
||||
{step === 'name' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Name Your Wallet</h2>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">
|
||||
Wallet Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={walletName}
|
||||
onChange={(e) => setWalletName(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Hot Wallet"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => setStep('type')}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!walletName.trim()}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Generating */}
|
||||
{step === 'generate' && (
|
||||
<div className="flex flex-col items-center py-12">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-2 border-muted border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Running distributed key generation...
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This may take up to 30 seconds.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Result */}
|
||||
{step === 'result' && result && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-400">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">Wallet Created</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border bg-muted/50 p-4">
|
||||
{result.eth_address && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">ETH Address</p>
|
||||
<p className="font-mono text-sm break-all">{result.eth_address}</p>
|
||||
</div>
|
||||
)}
|
||||
{result.btc_address && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">BTC Address</p>
|
||||
<p className="font-mono text-sm break-all">{result.btc_address}</p>
|
||||
</div>
|
||||
)}
|
||||
{result.sol_address && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">SOL Address</p>
|
||||
<p className="font-mono text-sm break-all">{result.sol_address}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Public Key</p>
|
||||
<p className="font-mono text-xs break-all">{result.public_key}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => router.push(`/vaults/${params.id}`)}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Vault } from '@/lib/types'
|
||||
|
||||
export default function VaultsPage() {
|
||||
const [vaults, setVaults] = useState<Vault[]>([])
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.listVaults().then(setVaults).catch(console.error)
|
||||
}, [])
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
await api.createVault({ name, description })
|
||||
setShowCreate(false)
|
||||
setName('')
|
||||
setDescription('')
|
||||
api.listVaults().then(setVaults).catch(console.error)
|
||||
} catch {
|
||||
// error handled by API client
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Vaults</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Manage your MPC key vaults.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Create Vault
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create vault dialog */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
<h2 className="mb-4 text-lg font-semibold">Create Vault</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Treasury Vault"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !name.trim()}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vault cards grid */}
|
||||
{vaults.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-card p-12 text-center">
|
||||
<p className="text-muted-foreground">No vaults yet. Create one to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{vaults.map((vault) => (
|
||||
<Link
|
||||
key={vault.id}
|
||||
href={`/vaults/${vault.id}`}
|
||||
className="group rounded-lg border border-border bg-card p-6 transition-colors hover:border-foreground/20"
|
||||
>
|
||||
<h3 className="font-semibold group-hover:text-foreground">
|
||||
{vault.name}
|
||||
</h3>
|
||||
{vault.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
|
||||
{vault.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{new Date(vault.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { StatusBadge } from '@/components/common/status-badge'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Wallet, WalletAddresses, Transaction } from '@/lib/types'
|
||||
|
||||
export default function WalletDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [wallet, setWallet] = useState<Wallet | null>(null)
|
||||
const [addresses, setAddresses] = useState<WalletAddresses | null>(null)
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
api.getWallet(id).then(setWallet).catch((e) => setError(e.message))
|
||||
api.getWalletAddresses(id).then(setAddresses).catch(() => {})
|
||||
api.getWalletHistory(id).then(setTransactions).catch(() => {})
|
||||
}, [id])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href="/vaults" className="hover:text-foreground">Vaults</Link>
|
||||
<span>/</span>
|
||||
<span>Wallet</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
|
||||
|
||||
{wallet && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{wallet.name || 'Unnamed Wallet'}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<span className="font-mono">{wallet.key_type}</span>
|
||||
{' -- '}
|
||||
Threshold: {wallet.threshold} of {wallet.participants.length}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<StatusBadge status={wallet.status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/wallets/${id}/reshare`}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
Reshare
|
||||
</Link>
|
||||
<Link
|
||||
href={`/wallets/${id}/smart-wallets`}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
Smart Wallets
|
||||
</Link>
|
||||
<Link
|
||||
href={`/wallets/${id}/send`}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Send
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wallet Info */}
|
||||
<div className="mb-8 rounded-lg border border-border bg-card p-6">
|
||||
<h2 className="mb-4 text-sm font-medium text-muted-foreground">Wallet Info</h2>
|
||||
<dl className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Wallet ID</dt>
|
||||
<dd className="mt-1 font-mono text-xs">{wallet.wallet_id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Key Type</dt>
|
||||
<dd className="mt-1 font-mono">{wallet.key_type}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Threshold</dt>
|
||||
<dd className="mt-1">{wallet.threshold} of {wallet.participants.length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Version</dt>
|
||||
<dd className="mt-1">{wallet.version}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Created</dt>
|
||||
<dd className="mt-1">{new Date(wallet.created_at).toLocaleDateString()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* Participants */}
|
||||
<div className="mt-6">
|
||||
<h3 className="mb-2 text-sm font-medium text-muted-foreground">Participants</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{wallet.participants.map((p) => (
|
||||
<span key={p} className="rounded-md border border-border bg-muted/50 px-2 py-1 font-mono text-xs">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Addresses */}
|
||||
<div className="mb-8 rounded-lg border border-border bg-card p-6">
|
||||
<h2 className="mb-4 text-sm font-medium text-muted-foreground">Addresses</h2>
|
||||
<div className="space-y-3">
|
||||
{(wallet.eth_address || addresses?.ethereum) && (
|
||||
<div className="rounded-md bg-muted/50 px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">Ethereum / EVM</p>
|
||||
<p className="font-mono text-sm">{wallet.eth_address || addresses?.ethereum}</p>
|
||||
</div>
|
||||
)}
|
||||
{(wallet.btc_address || addresses?.bitcoin) && (
|
||||
<div className="rounded-md bg-muted/50 px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">Bitcoin</p>
|
||||
<p className="font-mono text-sm">{wallet.btc_address || addresses?.bitcoin}</p>
|
||||
</div>
|
||||
)}
|
||||
{(wallet.sol_address || addresses?.solana) && (
|
||||
<div className="rounded-md bg-muted/50 px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">Solana</p>
|
||||
<p className="font-mono text-sm">{wallet.sol_address || addresses?.solana}</p>
|
||||
</div>
|
||||
)}
|
||||
{!wallet.eth_address && !wallet.btc_address && !wallet.sol_address &&
|
||||
!addresses?.ethereum && !addresses?.bitcoin && !addresses?.solana && (
|
||||
<p className="text-sm text-muted-foreground">No addresses generated yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Transactions */}
|
||||
<h2 className="mb-4 text-lg font-semibold">Recent Transactions</h2>
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Type</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">To</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Amount</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.map((tx) => (
|
||||
<tr key={tx.id} className="border-b border-border last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 capitalize">{tx.tx_type}</td>
|
||||
<td className="px-4 py-3 capitalize">{tx.chain}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
{tx.to_address ? `${tx.to_address.slice(0, 10)}...${tx.to_address.slice(-6)}` : '--'}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono">{tx.amount || '--'}</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={tx.status} /></td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{new Date(tx.created_at).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{transactions.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No transactions yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!wallet && !error && (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { ReshareWizard } from '@/components/wallets/reshare-wizard'
|
||||
|
||||
interface WalletInfo {
|
||||
id: string
|
||||
name: string
|
||||
threshold: number
|
||||
participants: string[]
|
||||
ecdsaPubkey: string
|
||||
}
|
||||
|
||||
type ReshareStatus = 'idle' | 'confirming' | 'running' | 'success' | 'error'
|
||||
|
||||
export default function ResharePage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
|
||||
// TODO: fetch wallet info from API
|
||||
const [wallet] = useState<WalletInfo>({
|
||||
id: params.id,
|
||||
name: 'Vault Wallet 1',
|
||||
threshold: 2,
|
||||
participants: ['node0', 'node1', 'node2'],
|
||||
ecdsaPubkey: '0x04abc...def',
|
||||
})
|
||||
|
||||
const [newThreshold, setNewThreshold] = useState(wallet.threshold)
|
||||
const [newParticipants, setNewParticipants] = useState(
|
||||
wallet.participants.join('\n')
|
||||
)
|
||||
const [status, setStatus] = useState<ReshareStatus>('idle')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const participantList = newParticipants
|
||||
.split('\n')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
async function handleConfirm() {
|
||||
setStatus('confirming')
|
||||
}
|
||||
|
||||
async function handleReshare() {
|
||||
setStatus('running')
|
||||
setError('')
|
||||
try {
|
||||
// TODO: call MPC reshare API
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
setStatus('success')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Reshare failed')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-8 px-4 py-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Reshare Key Shares
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Rotate key shares for wallet{' '}
|
||||
<span className="font-mono">{wallet.name}</span> without changing the
|
||||
public key or on-chain addresses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current wallet info */}
|
||||
<section className="rounded-lg border border-border bg-card p-6 space-y-4">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Current Configuration
|
||||
</h2>
|
||||
<dl className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Threshold</dt>
|
||||
<dd className="mt-1 font-mono">
|
||||
{wallet.threshold}-of-{wallet.participants.length}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Participants</dt>
|
||||
<dd className="mt-1 font-mono">
|
||||
{wallet.participants.length} nodes
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="text-muted-foreground">ECDSA Public Key</dt>
|
||||
<dd className="mt-1 truncate font-mono text-xs">
|
||||
{wallet.ecdsaPubkey}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="rounded-lg border border-yellow-600/30 bg-yellow-600/10 p-4 text-sm">
|
||||
<p className="font-medium text-yellow-500">Key Rotation Warning</p>
|
||||
<p className="mt-1 text-yellow-500/80">
|
||||
Resharing invalidates all existing key shares. All participants must be
|
||||
online and reachable during the process. Ensure backups exist before
|
||||
proceeding.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status === 'idle' && (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="threshold">
|
||||
New Threshold
|
||||
</label>
|
||||
<input
|
||||
id="threshold"
|
||||
type="number"
|
||||
min={1}
|
||||
max={participantList.length}
|
||||
value={newThreshold}
|
||||
onChange={(e) => setNewThreshold(Number(e.target.value))}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Minimum signers required (1 to {participantList.length})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="participants">
|
||||
New Participants (one per line)
|
||||
</label>
|
||||
<textarea
|
||||
id="participants"
|
||||
rows={5}
|
||||
value={newParticipants}
|
||||
onChange={(e) => setNewParticipants(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{participantList.length} participant
|
||||
{participantList.length !== 1 ? 's' : ''} configured
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={participantList.length < 1 || newThreshold < 1}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Review Reshare
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{status === 'confirming' && (
|
||||
<section className="space-y-6">
|
||||
<div className="rounded-lg border border-border bg-card p-6 space-y-3 text-sm">
|
||||
<h2 className="font-medium">Confirm Reshare</h2>
|
||||
<p>
|
||||
<span className="text-muted-foreground">New threshold:</span>{' '}
|
||||
<span className="font-mono">
|
||||
{newThreshold}-of-{participantList.length}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground">New participants:</p>
|
||||
<ul className="list-inside list-disc font-mono text-xs space-y-1">
|
||||
{participantList.map((p) => (
|
||||
<li key={p}>{p}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setStatus('idle')}
|
||||
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReshare}
|
||||
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Confirm Reshare
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{status === 'running' && (
|
||||
<ReshareWizard
|
||||
step="resharing"
|
||||
threshold={newThreshold}
|
||||
participants={participantList}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<section className="rounded-lg border border-green-600/30 bg-green-600/10 p-6 text-center space-y-4">
|
||||
<p className="text-lg font-medium text-green-500">
|
||||
Reshare Complete
|
||||
</p>
|
||||
<p className="text-sm text-green-500/80">
|
||||
Key shares have been rotated. New threshold:{' '}
|
||||
<span className="font-mono">
|
||||
{newThreshold}-of-{participantList.length}
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push(`/wallets/${params.id}`)}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Back to Wallet
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-6 text-center space-y-4">
|
||||
<p className="text-lg font-medium text-destructive">
|
||||
Reshare Failed
|
||||
</p>
|
||||
<p className="text-sm text-destructive/80">{error}</p>
|
||||
<button
|
||||
onClick={() => setStatus('idle')}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const chains = [
|
||||
{ value: 'ethereum', label: 'Ethereum' },
|
||||
{ value: 'bitcoin', label: 'Bitcoin' },
|
||||
{ value: 'lux', label: 'Lux' },
|
||||
{ value: 'solana', label: 'Solana' },
|
||||
{ value: 'xrpl', label: 'XRPL' },
|
||||
]
|
||||
|
||||
const txTypes = [
|
||||
{ value: 'transfer', label: 'Transfer' },
|
||||
{ value: 'contract_call', label: 'Contract Call' },
|
||||
]
|
||||
|
||||
export default function SendPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
|
||||
const [chain, setChain] = useState('ethereum')
|
||||
const [toAddress, setToAddress] = useState('')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [txType, setTxType] = useState('transfer')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!showPreview) {
|
||||
setShowPreview(true)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const tx = await api.createTransaction({
|
||||
wallet_id: id,
|
||||
tx_type: txType,
|
||||
chain,
|
||||
to_address: toAddress,
|
||||
amount,
|
||||
token: token || undefined,
|
||||
})
|
||||
router.push(`/transactions/${tx.id}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Transaction failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href={`/wallets/${id}`} className="hover:text-foreground">Wallet</Link>
|
||||
<span>/</span>
|
||||
<span>Send</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mb-8 text-2xl font-semibold tracking-tight">Send Transaction</h1>
|
||||
|
||||
<div className="mx-auto max-w-lg">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">Chain</label>
|
||||
<select
|
||||
value={chain}
|
||||
onChange={(e) => setChain(e.target.value)}
|
||||
disabled={showPreview}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{chains.map((c) => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">Transaction Type</label>
|
||||
<select
|
||||
value={txType}
|
||||
onChange={(e) => setTxType(e.target.value)}
|
||||
disabled={showPreview}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{txTypes.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">To Address</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={toAddress}
|
||||
onChange={(e) => setToAddress(e.target.value)}
|
||||
disabled={showPreview}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="0x..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">Amount</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
disabled={showPreview}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="0.0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">Token (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
disabled={showPreview}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Contract address or leave blank for native"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-4 space-y-2">
|
||||
<h3 className="text-sm font-medium">Preview</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Chain</span>
|
||||
<span className="capitalize">{chain}</span>
|
||||
<span className="text-muted-foreground">Type</span>
|
||||
<span className="capitalize">{txType.replace('_', ' ')}</span>
|
||||
<span className="text-muted-foreground">To</span>
|
||||
<span className="font-mono text-xs break-all">{toAddress}</span>
|
||||
<span className="text-muted-foreground">Amount</span>
|
||||
<span className="font-mono">{amount}</span>
|
||||
{token && (
|
||||
<>
|
||||
<span className="text-muted-foreground">Token</span>
|
||||
<span className="font-mono text-xs break-all">{token}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex justify-between pt-2">
|
||||
{showPreview ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(false)}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Submitting...' : 'Confirm & Send'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-auto rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { DeployWizard } from '@/components/smart-wallets/deploy-wizard'
|
||||
|
||||
type WalletType = 'safe' | 'erc4337'
|
||||
type DeployStep = 'type' | 'config' | 'advanced' | 'deploy'
|
||||
|
||||
interface DeployConfig {
|
||||
type: WalletType
|
||||
chain: string
|
||||
chainId: number
|
||||
owners: string[]
|
||||
threshold: number
|
||||
factoryAddress: string
|
||||
salt: string
|
||||
}
|
||||
|
||||
const chains = [
|
||||
{ name: 'Ethereum', chainId: 1 },
|
||||
{ name: 'Lux C-Chain', chainId: 96369 },
|
||||
{ name: 'Lux Testnet', chainId: 96368 },
|
||||
{ name: 'Zoo', chainId: 200200 },
|
||||
{ name: 'Hanzo', chainId: 36963 },
|
||||
]
|
||||
|
||||
export default function DeploySmartWalletPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
|
||||
const [step, setStep] = useState<DeployStep>('type')
|
||||
const [config, setConfig] = useState<DeployConfig>({
|
||||
type: 'safe',
|
||||
chain: 'Ethereum',
|
||||
chainId: 1,
|
||||
owners: [''],
|
||||
threshold: 1,
|
||||
factoryAddress: '',
|
||||
salt: '',
|
||||
})
|
||||
const [deploying, setDeploying] = useState(false)
|
||||
const [deployedAddress, setDeployedAddress] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
function addOwner() {
|
||||
setConfig((c) => ({ ...c, owners: [...c.owners, ''] }))
|
||||
}
|
||||
|
||||
function updateOwner(index: number, value: string) {
|
||||
setConfig((c) => {
|
||||
const owners = [...c.owners]
|
||||
owners[index] = value
|
||||
return { ...c, owners }
|
||||
})
|
||||
}
|
||||
|
||||
function removeOwner(index: number) {
|
||||
setConfig((c) => ({
|
||||
...c,
|
||||
owners: c.owners.filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
async function handleDeploy() {
|
||||
setDeploying(true)
|
||||
setError('')
|
||||
try {
|
||||
// TODO: call deploy API
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
setDeployedAddress('0x9876...fedc')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Deployment failed')
|
||||
} finally {
|
||||
setDeploying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-8 px-4 py-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Deploy Smart Wallet
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Deploy a Safe or ERC-4337 wallet backed by MPC key{' '}
|
||||
<span className="font-mono">{params.id}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<DeployWizard currentStep={step} />
|
||||
|
||||
{/* Step 1: Choose type */}
|
||||
{step === 'type' && (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Step 1: Choose Wallet Type
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{(['safe', 'erc4337'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setConfig((c) => ({ ...c, type: t }))}
|
||||
className={`rounded-lg border p-6 text-left transition-colors ${
|
||||
config.type === t
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{t === 'safe' ? 'Safe (Gnosis Safe)' : 'ERC-4337 Account'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t === 'safe'
|
||||
? 'Multi-sig smart wallet with on-chain approvals'
|
||||
: 'Account abstraction with bundler and paymaster support'}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setStep('config')}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Step 2: Chain, owners, threshold */}
|
||||
{step === 'config' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Step 2: Configuration
|
||||
</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="chain">
|
||||
Chain
|
||||
</label>
|
||||
<select
|
||||
id="chain"
|
||||
value={config.chainId}
|
||||
onChange={(e) => {
|
||||
const chain = chains.find(
|
||||
(c) => c.chainId === Number(e.target.value)
|
||||
)
|
||||
if (chain) {
|
||||
setConfig((c) => ({
|
||||
...c,
|
||||
chain: chain.name,
|
||||
chainId: chain.chainId,
|
||||
}))
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{chains.map((c) => (
|
||||
<option key={c.chainId} value={c.chainId}>
|
||||
{c.name} ({c.chainId})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Owners</label>
|
||||
{config.owners.map((owner, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={owner}
|
||||
onChange={(e) => updateOwner(i, e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="flex-1 rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
{config.owners.length > 1 && (
|
||||
<button
|
||||
onClick={() => removeOwner(i)}
|
||||
className="rounded-md border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={addOwner}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
+ Add Owner
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="sw-threshold">
|
||||
Threshold
|
||||
</label>
|
||||
<input
|
||||
id="sw-threshold"
|
||||
type="number"
|
||||
min={1}
|
||||
max={config.owners.length}
|
||||
value={config.threshold}
|
||||
onChange={(e) =>
|
||||
setConfig((c) => ({ ...c, threshold: Number(e.target.value) }))
|
||||
}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setStep('type')}
|
||||
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep('advanced')}
|
||||
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Step 3: Advanced options */}
|
||||
{step === 'advanced' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Step 3: Advanced Options (Optional)
|
||||
</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="factory">
|
||||
Factory Address
|
||||
</label>
|
||||
<input
|
||||
id="factory"
|
||||
type="text"
|
||||
value={config.factoryAddress}
|
||||
onChange={(e) =>
|
||||
setConfig((c) => ({ ...c, factoryAddress: e.target.value }))
|
||||
}
|
||||
placeholder="Leave empty for default"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="salt">
|
||||
Salt (CREATE2)
|
||||
</label>
|
||||
<input
|
||||
id="salt"
|
||||
type="text"
|
||||
value={config.salt}
|
||||
onChange={(e) =>
|
||||
setConfig((c) => ({ ...c, salt: e.target.value }))
|
||||
}
|
||||
placeholder="Leave empty for random"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setStep('config')}
|
||||
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep('deploy')}
|
||||
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Review & Deploy
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Step 4: Deploy */}
|
||||
{step === 'deploy' && !deployedAddress && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Step 4: Deploy
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-6 space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Type</span>
|
||||
<span className="font-mono">
|
||||
{config.type === 'safe' ? 'Gnosis Safe' : 'ERC-4337'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Chain</span>
|
||||
<span>
|
||||
{config.chain} ({config.chainId})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Threshold</span>
|
||||
<span className="font-mono">
|
||||
{config.threshold}-of-{config.owners.filter(Boolean).length}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Owners</span>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{config.owners.filter(Boolean).map((o, i) => (
|
||||
<li key={i} className="truncate font-mono text-xs">
|
||||
{o}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setStep('advanced')}
|
||||
disabled={deploying}
|
||||
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeploy}
|
||||
disabled={deploying}
|
||||
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{deploying ? 'Deploying...' : 'Deploy'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{deploying && (
|
||||
<div className="flex items-center justify-center gap-3 py-4">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Deploying contract...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{deployedAddress && (
|
||||
<section className="rounded-lg border border-green-600/30 bg-green-600/10 p-6 text-center space-y-4">
|
||||
<p className="text-lg font-medium text-green-500">
|
||||
Smart Wallet Deployed
|
||||
</p>
|
||||
<p className="truncate font-mono text-sm text-green-500/80">
|
||||
{deployedAddress}
|
||||
</p>
|
||||
<button
|
||||
onClick={() =>
|
||||
router.push(`/wallets/${params.id}/smart-wallets`)
|
||||
}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
View Smart Wallets
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { SmartWalletCard } from '@/components/smart-wallets/smart-wallet-card'
|
||||
|
||||
export interface SmartWallet {
|
||||
id: string
|
||||
type: 'safe' | 'erc4337'
|
||||
chain: string
|
||||
chainId: number
|
||||
contractAddress: string
|
||||
threshold: number
|
||||
owners: string[]
|
||||
status: 'deployed' | 'pending' | 'failed'
|
||||
}
|
||||
|
||||
export default function SmartWalletsPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
|
||||
// TODO: fetch smart wallets from API
|
||||
const [wallets] = useState<SmartWallet[]>([
|
||||
{
|
||||
id: 'sw-1',
|
||||
type: 'safe',
|
||||
chain: 'Ethereum',
|
||||
chainId: 1,
|
||||
contractAddress: '0x1234...abcd',
|
||||
threshold: 2,
|
||||
owners: ['0xaaa...111', '0xbbb...222', '0xccc...333'],
|
||||
status: 'deployed',
|
||||
},
|
||||
{
|
||||
id: 'sw-2',
|
||||
type: 'erc4337',
|
||||
chain: 'Lux C-Chain',
|
||||
chainId: 96369,
|
||||
contractAddress: '0x5678...efgh',
|
||||
threshold: 1,
|
||||
owners: ['0xaaa...111'],
|
||||
status: 'deployed',
|
||||
},
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-8 px-4 py-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Smart Wallets
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Smart contract wallets backed by MPC key{' '}
|
||||
<span className="font-mono">{params.id}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/wallets/${params.id}/smart-wallets/deploy`}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Deploy Smart Wallet
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{wallets.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-card p-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No smart wallets deployed yet.
|
||||
</p>
|
||||
<Link
|
||||
href={`/wallets/${params.id}/smart-wallets/deploy`}
|
||||
className="mt-4 inline-block text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Deploy your first smart wallet
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{wallets.map((sw) => (
|
||||
<SmartWalletCard key={sw.id} wallet={sw} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, type FormEvent } from 'react'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { api } from '@/lib/api'
|
||||
import type { AddressWhitelist } from '@/lib/types'
|
||||
|
||||
const chains = ['ethereum', 'bitcoin', 'lux', 'solana', 'xrpl', 'zoo', 'hanzo']
|
||||
|
||||
export default function WhitelistPage() {
|
||||
const [entries, setEntries] = useState<AddressWhitelist[]>([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Form state
|
||||
const [newAddress, setNewAddress] = useState('')
|
||||
const [newChain, setNewChain] = useState(chains[0])
|
||||
const [newLabel, setNewLabel] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.listWhitelist().then(setEntries).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
async function handleAdd(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setAdding(true)
|
||||
setError('')
|
||||
try {
|
||||
const entry = await api.addWhitelist({
|
||||
address: newAddress,
|
||||
chain: newChain,
|
||||
label: newLabel || undefined,
|
||||
})
|
||||
setEntries((prev) => [entry, ...prev])
|
||||
setNewAddress('')
|
||||
setNewLabel('')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to add address')
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await api.deleteWhitelist(id)
|
||||
setEntries((prev) => prev.filter((e) => e.id !== id))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to delete')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Address Whitelist</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Manage approved addresses for outgoing transactions.</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
|
||||
|
||||
{/* Add form */}
|
||||
<form onSubmit={handleAdd} className="mb-8 rounded-lg border border-border bg-card p-6 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Add Address</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="sm:col-span-2 space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Address</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={newAddress}
|
||||
onChange={(e) => setNewAddress(e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Chain</label>
|
||||
<select
|
||||
value={newChain}
|
||||
onChange={(e) => setNewChain(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{chains.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-muted-foreground">Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
placeholder="e.g. Treasury, Exchange, Partner"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={adding || !newAddress}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{adding ? 'Adding...' : 'Add to Whitelist'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Address</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Label</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Added By</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Date</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
<span className="block max-w-[200px] truncate">{entry.address}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 capitalize">{entry.chain}</td>
|
||||
<td className="px-4 py-3">{entry.label || '--'}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">
|
||||
{entry.created_by || '--'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{new Date(entry.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(entry.id)}
|
||||
className="text-sm text-destructive hover:underline"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{entries.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No whitelisted addresses yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const chainMeta: Record<string, { label: string; color: string }> = {
|
||||
ethereum: { label: 'Ethereum', color: 'text-blue-400' },
|
||||
bitcoin: { label: 'Bitcoin', color: 'text-amber-400' },
|
||||
solana: { label: 'Solana', color: 'text-purple-400' },
|
||||
lux: { label: 'Lux', color: 'text-zinc-300' },
|
||||
xrpl: { label: 'XRPL', color: 'text-zinc-400' },
|
||||
ton: { label: 'TON', color: 'text-cyan-400' },
|
||||
}
|
||||
|
||||
interface ChainIconProps {
|
||||
chain: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ChainIcon({ chain, className = '' }: ChainIconProps) {
|
||||
const meta = chainMeta[chain.toLowerCase()] ?? { label: chain, color: 'text-muted-foreground' }
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${meta.color} ${className}`}>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-bold uppercase">
|
||||
{meta.label.charAt(0)}
|
||||
</span>
|
||||
{meta.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
export interface Column<T> {
|
||||
key: string
|
||||
header: string
|
||||
render?: (row: T) => React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[]
|
||||
data: T[]
|
||||
keyField: string
|
||||
emptyMessage?: string
|
||||
onRowClick?: (row: T) => void
|
||||
}
|
||||
|
||||
export function DataTable<T extends Record<string, any>>({
|
||||
columns,
|
||||
data,
|
||||
keyField,
|
||||
emptyMessage = 'No data found.',
|
||||
onRowClick,
|
||||
}: DataTableProps<T>) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-4 py-3 text-left font-medium text-muted-foreground ${col.className ?? ''}`}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<tr
|
||||
key={String(row[keyField])}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={`border-b border-border transition-colors last:border-0 ${
|
||||
onRowClick ? 'cursor-pointer hover:bg-muted/50' : ''
|
||||
}`}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={`px-4 py-3 ${col.className ?? ''}`}>
|
||||
{col.render
|
||||
? col.render(row)
|
||||
: String(row[col.key] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
const statusColors: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
running: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
signed: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
approved: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
enabled: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
pending: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
pending_approval: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
signing: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
processing: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
failed: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
rejected: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
cancelled: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
disabled: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20',
|
||||
paused: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20',
|
||||
inactive: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20',
|
||||
}
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, className = '' }: StatusBadgeProps) {
|
||||
const colorClasses = statusColors[status.toLowerCase()] ?? 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20'
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium capitalize ${colorClasses} ${className}`}
|
||||
>
|
||||
{status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { UserMenu } from './user-menu'
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/' as const, label: 'Dashboard' },
|
||||
{ href: '/vaults' as const, label: 'Vaults' },
|
||||
{ href: '/transactions' as const, label: 'Transactions' },
|
||||
{ href: '/policies' as const, label: 'Policies' },
|
||||
{ href: '/team' as const, label: 'Team' },
|
||||
{ href: '/settings' as const, label: 'Settings' },
|
||||
]
|
||||
|
||||
export function Nav() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold tracking-tight">Lux MPC</span>
|
||||
</Link>
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
{navLinks.map((link) => {
|
||||
const isActive =
|
||||
link.href === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(link.href)
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { clearTokens } from '@/lib/auth'
|
||||
|
||||
export function UserMenu() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
clearTokens()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
||||
U
|
||||
</div>
|
||||
<span className="hidden sm:inline">user@example.com</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1 w-48 rounded-md border border-border bg-card p-1 shadow-lg">
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setOpen(false)}
|
||||
className="block rounded-sm px-3 py-2 text-sm text-card-foreground hover:bg-accent"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setOpen(false)}
|
||||
className="block rounded-sm px-3 py-2 text-sm text-card-foreground hover:bg-accent"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<hr className="my-1 border-border" />
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="block w-full rounded-sm px-3 py-2 text-left text-sm text-destructive hover:bg-accent"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
const chains = [
|
||||
{ name: 'Ethereum', chainId: 1 },
|
||||
{ name: 'Lux C-Chain', chainId: 96369 },
|
||||
{ name: 'Lux Testnet', chainId: 96368 },
|
||||
{ name: 'Zoo', chainId: 200200 },
|
||||
{ name: 'Hanzo', chainId: 36963 },
|
||||
]
|
||||
|
||||
export interface PaymentRequestFormData {
|
||||
walletId: string
|
||||
merchantName: string
|
||||
recipientAddress: string
|
||||
chain: string
|
||||
chainId: number
|
||||
amount: string
|
||||
memo: string
|
||||
expiryHours: number
|
||||
}
|
||||
|
||||
export interface PaymentRequestFormProps {
|
||||
walletId?: string
|
||||
onSubmit: (data: PaymentRequestFormData) => void
|
||||
onCancel: () => void
|
||||
submitting?: boolean
|
||||
}
|
||||
|
||||
export function PaymentRequestForm({
|
||||
walletId = '',
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitting = false,
|
||||
}: PaymentRequestFormProps) {
|
||||
const [form, setForm] = useState<PaymentRequestFormData>({
|
||||
walletId,
|
||||
merchantName: '',
|
||||
recipientAddress: '',
|
||||
chain: chains[0].name,
|
||||
chainId: chains[0].chainId,
|
||||
amount: '',
|
||||
memo: '',
|
||||
expiryHours: 24,
|
||||
})
|
||||
|
||||
function update<K extends keyof PaymentRequestFormData>(
|
||||
field: K,
|
||||
value: PaymentRequestFormData[K]
|
||||
) {
|
||||
setForm((f) => ({ ...f, [field]: value }))
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
onSubmit(form)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{!walletId && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="pr-wallet">
|
||||
Wallet ID (optional)
|
||||
</label>
|
||||
<input
|
||||
id="pr-wallet"
|
||||
type="text"
|
||||
value={form.walletId}
|
||||
onChange={(e) => update('walletId', e.target.value)}
|
||||
placeholder="Leave empty for any wallet"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="pr-merchant">
|
||||
Merchant Name
|
||||
</label>
|
||||
<input
|
||||
id="pr-merchant"
|
||||
type="text"
|
||||
value={form.merchantName}
|
||||
onChange={(e) => update('merchantName', e.target.value)}
|
||||
placeholder="Your business name"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="pr-recipient">
|
||||
Recipient Address
|
||||
</label>
|
||||
<input
|
||||
id="pr-recipient"
|
||||
type="text"
|
||||
required
|
||||
value={form.recipientAddress}
|
||||
onChange={(e) => update('recipientAddress', e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="pr-chain">
|
||||
Chain
|
||||
</label>
|
||||
<select
|
||||
id="pr-chain"
|
||||
value={form.chainId}
|
||||
onChange={(e) => {
|
||||
const chain = chains.find((c) => c.chainId === Number(e.target.value))
|
||||
if (chain) {
|
||||
setForm((f) => ({ ...f, chain: chain.name, chainId: chain.chainId }))
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{chains.map((c) => (
|
||||
<option key={c.chainId} value={c.chainId}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="pr-amount">
|
||||
Amount
|
||||
</label>
|
||||
<input
|
||||
id="pr-amount"
|
||||
type="text"
|
||||
required
|
||||
value={form.amount}
|
||||
onChange={(e) => update('amount', e.target.value)}
|
||||
placeholder="e.g. 100 USDC"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="pr-memo">
|
||||
Memo
|
||||
</label>
|
||||
<input
|
||||
id="pr-memo"
|
||||
type="text"
|
||||
value={form.memo}
|
||||
onChange={(e) => update('memo', e.target.value)}
|
||||
placeholder="Optional note"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="pr-expiry">
|
||||
Expiry (hours)
|
||||
</label>
|
||||
<input
|
||||
id="pr-expiry"
|
||||
type="number"
|
||||
min={1}
|
||||
max={720}
|
||||
value={form.expiryHours}
|
||||
onChange={(e) => update('expiryHours', Number(e.target.value))}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !form.recipientAddress || !form.amount}
|
||||
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create Request'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export interface PaymentRequest {
|
||||
id: string
|
||||
merchantName: string
|
||||
recipientAddress: string
|
||||
chain: string
|
||||
amount: string
|
||||
status: 'open' | 'paid' | 'expired' | 'cancelled'
|
||||
expiresAt: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PaymentRequestTableProps {
|
||||
requests: PaymentRequest[]
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: 'bg-blue-600/20 text-blue-400',
|
||||
paid: 'bg-green-600/20 text-green-500',
|
||||
expired: 'bg-zinc-600/20 text-zinc-400',
|
||||
cancelled: 'bg-red-600/20 text-red-400',
|
||||
}
|
||||
|
||||
export function PaymentRequestTable({ requests }: PaymentRequestTableProps) {
|
||||
if (requests.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
|
||||
No payment requests found.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Merchant</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Recipient</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Amount</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{requests.map((req) => (
|
||||
<tr key={req.id} className="border-b border-border last:border-0 hover:bg-muted/20">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/payments/${req.id}`} className="font-medium hover:underline">
|
||||
{req.merchantName || 'Unnamed'}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
<span className="truncate block max-w-[140px]">{req.recipientAddress}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">{req.chain}</td>
|
||||
<td className="px-4 py-3 text-right font-mono">{req.amount}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusColors[req.status] ?? ''}`}>
|
||||
{req.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-muted-foreground">
|
||||
{new Date(req.expiresAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export type PolicyType = 'spend_limit' | 'whitelist' | 'time_lock' | 'quorum' | 'custom'
|
||||
|
||||
export interface PolicyCondition {
|
||||
field: string
|
||||
operator: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface PolicyFormData {
|
||||
name: string
|
||||
type: PolicyType
|
||||
conditions: PolicyCondition[]
|
||||
appliedTo: string
|
||||
}
|
||||
|
||||
export interface PolicyFormProps {
|
||||
initialData?: Partial<PolicyFormData>
|
||||
onSubmit: (data: PolicyFormData) => void
|
||||
onCancel: () => void
|
||||
submitting?: boolean
|
||||
}
|
||||
|
||||
const policyTypes: { value: PolicyType; label: string }[] = [
|
||||
{ value: 'spend_limit', label: 'Spend Limit' },
|
||||
{ value: 'whitelist', label: 'Whitelist' },
|
||||
{ value: 'time_lock', label: 'Time Lock' },
|
||||
{ value: 'quorum', label: 'Quorum' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]
|
||||
|
||||
const operators = ['==', '!=', '>', '<', '>=', '<=', 'in', 'not_in']
|
||||
|
||||
const conditionFields: Record<PolicyType, string[]> = {
|
||||
spend_limit: ['amount', 'token', 'period'],
|
||||
whitelist: ['to_address', 'chain'],
|
||||
time_lock: ['delay_seconds', 'window_start', 'window_end'],
|
||||
quorum: ['required_approvals', 'approver_group'],
|
||||
custom: ['field'],
|
||||
}
|
||||
|
||||
export function PolicyForm({
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitting = false,
|
||||
}: PolicyFormProps) {
|
||||
const [name, setName] = useState(initialData?.name ?? '')
|
||||
const [type, setType] = useState<PolicyType>(initialData?.type ?? 'spend_limit')
|
||||
const [conditions, setConditions] = useState<PolicyCondition[]>(
|
||||
initialData?.conditions ?? [{ field: '', operator: '==', value: '' }]
|
||||
)
|
||||
const [appliedTo, setAppliedTo] = useState(initialData?.appliedTo ?? '')
|
||||
|
||||
function addCondition() {
|
||||
setConditions((c) => [...c, { field: '', operator: '==', value: '' }])
|
||||
}
|
||||
|
||||
function updateCondition(index: number, patch: Partial<PolicyCondition>) {
|
||||
setConditions((c) =>
|
||||
c.map((cond, i) => (i === index ? { ...cond, ...patch } : cond))
|
||||
)
|
||||
}
|
||||
|
||||
function removeCondition(index: number) {
|
||||
setConditions((c) => c.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
onSubmit({ name, type, conditions, appliedTo })
|
||||
}
|
||||
|
||||
const fields = conditionFields[type]
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="policy-name">
|
||||
Policy Name
|
||||
</label>
|
||||
<input
|
||||
id="policy-name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Daily Spend Limit"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="policy-type">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
id="policy-type"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as PolicyType)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{policyTypes.map((pt) => (
|
||||
<option key={pt.value} value={pt.value}>
|
||||
{pt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="policy-applied">
|
||||
Applied To (wallet or vault ID)
|
||||
</label>
|
||||
<input
|
||||
id="policy-applied"
|
||||
type="text"
|
||||
value={appliedTo}
|
||||
onChange={(e) => setAppliedTo(e.target.value)}
|
||||
placeholder="wallet-... or vault-... or * for all"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Conditions</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCondition}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
+ Add Condition
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{conditions.map((cond, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<select
|
||||
value={cond.field}
|
||||
onChange={(e) => updateCondition(i, { field: e.target.value })}
|
||||
className="w-1/3 rounded-md border border-input bg-background px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">Field...</option>
|
||||
{fields.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={cond.operator}
|
||||
onChange={(e) => updateCondition(i, { operator: e.target.value })}
|
||||
className="w-24 rounded-md border border-input bg-background px-2 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{operators.map((op) => (
|
||||
<option key={op} value={op}>
|
||||
{op}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={cond.value}
|
||||
onChange={(e) => updateCondition(i, { value: e.target.value })}
|
||||
placeholder="Value"
|
||||
className="flex-1 rounded-md border border-input bg-background px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
|
||||
{conditions.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCondition(i)}
|
||||
className="rounded-md border border-border px-2 py-2 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !name}
|
||||
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Saving...' : 'Save Policy'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export interface Policy {
|
||||
id: string
|
||||
name: string
|
||||
type: 'spend_limit' | 'whitelist' | 'time_lock' | 'quorum' | 'custom'
|
||||
status: 'active' | 'disabled'
|
||||
conditions: string
|
||||
appliedTo: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PolicyTableProps {
|
||||
policies: Policy[]
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
spend_limit: 'Spend Limit',
|
||||
whitelist: 'Whitelist',
|
||||
time_lock: 'Time Lock',
|
||||
quorum: 'Quorum',
|
||||
custom: 'Custom',
|
||||
}
|
||||
|
||||
export function PolicyTable({ policies }: PolicyTableProps) {
|
||||
if (policies.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
|
||||
No policies configured.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Type</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Conditions</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Applied To</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{policies.map((policy) => (
|
||||
<tr key={policy.id} className="border-b border-border last:border-0 hover:bg-muted/20">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/policies/${policy.id}`} className="font-medium hover:underline">
|
||||
{policy.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
{typeLabels[policy.type] ?? policy.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground max-w-[200px] truncate">
|
||||
{policy.conditions}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{policy.appliedTo}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
policy.status === 'active'
|
||||
? 'bg-green-600/20 text-green-500'
|
||||
: 'bg-zinc-600/20 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{policy.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-muted-foreground">
|
||||
{new Date(policy.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
interface DeployWizardProps {
|
||||
currentStep: 'type' | 'config' | 'advanced' | 'deploy'
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ key: 'type', label: 'Type' },
|
||||
{ key: 'config', label: 'Config' },
|
||||
{ key: 'advanced', label: 'Advanced' },
|
||||
{ key: 'deploy', label: 'Deploy' },
|
||||
] as const
|
||||
|
||||
export function DeployWizard({ currentStep }: DeployWizardProps) {
|
||||
const currentIndex = steps.findIndex((s) => s.key === currentStep)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, i) => (
|
||||
<div key={step.key} className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold ${
|
||||
step.key === currentStep
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: i < currentIndex
|
||||
? 'bg-emerald-500/20 text-emerald-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{i < currentIndex ? (
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
i + 1
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`hidden text-xs font-medium sm:inline ${
|
||||
step.key === currentStep ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
{i < steps.length - 1 && <div className="mx-2 h-px w-8 bg-border sm:w-12" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
interface PendingSafeTx {
|
||||
id: string
|
||||
to: string
|
||||
value: string
|
||||
data: string
|
||||
nonce: number
|
||||
confirmations: number
|
||||
required: number
|
||||
submittedBy: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface SafeTxQueueProps {
|
||||
transactions: PendingSafeTx[]
|
||||
walletId: string
|
||||
}
|
||||
|
||||
export function SafeTxQueue({ transactions, walletId }: SafeTxQueueProps) {
|
||||
if (transactions.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
|
||||
No pending transactions.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function handleConfirm(txId: string) {
|
||||
// TODO: call API to confirm Safe transaction
|
||||
console.log('Confirm tx', txId, 'on wallet', walletId)
|
||||
}
|
||||
|
||||
async function handleExecute(txId: string) {
|
||||
// TODO: call API to execute Safe transaction
|
||||
console.log('Execute tx', txId, 'on wallet', walletId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{transactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="rounded-lg border border-border bg-card p-4 space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
Nonce #{tx.nonce}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tx.confirmations}/{tx.required} confirmations
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">To</p>
|
||||
<p className="truncate font-mono text-xs">{tx.to}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Value</p>
|
||||
<p className="font-mono text-xs">{tx.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
{tx.data !== '0x' && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Data</p>
|
||||
<p className="truncate font-mono text-xs">{tx.data}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
By: {tx.submittedBy} at {new Date(tx.createdAt).toLocaleString()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleConfirm(tx.id)}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
{tx.confirmations >= tx.required && (
|
||||
<button
|
||||
onClick={() => handleExecute(tx.id)}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Execute
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${(tx.confirmations / tx.required) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from 'next/link'
|
||||
import { StatusBadge } from '@/components/common/status-badge'
|
||||
|
||||
interface SmartWallet {
|
||||
id: string
|
||||
type: 'safe' | 'erc4337'
|
||||
chain: string
|
||||
chainId: number
|
||||
contractAddress: string
|
||||
threshold: number
|
||||
owners: string[]
|
||||
status: 'deployed' | 'pending' | 'failed'
|
||||
}
|
||||
|
||||
interface SmartWalletCardProps {
|
||||
wallet: SmartWallet
|
||||
}
|
||||
|
||||
export function SmartWalletCard({ wallet }: SmartWalletCardProps) {
|
||||
return (
|
||||
<Link
|
||||
href={`/smart-wallets/${wallet.id}`}
|
||||
className="group rounded-lg border border-border bg-card p-6 transition-colors hover:border-foreground/20"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold">
|
||||
{wallet.type === 'safe' ? 'Safe' : 'ERC-4337'}
|
||||
</span>
|
||||
<StatusBadge status={wallet.status === 'deployed' ? 'active' : wallet.status} />
|
||||
</div>
|
||||
<p className="mt-2 truncate font-mono text-xs text-muted-foreground">
|
||||
{wallet.contractAddress}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{wallet.chain} ({wallet.chainId})
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{wallet.threshold}-of-{wallet.owners.length}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface PaymentRecord {
|
||||
id: string
|
||||
txHash: string
|
||||
amount: string
|
||||
token: string
|
||||
status: 'confirmed' | 'pending' | 'failed'
|
||||
paidAt: string
|
||||
}
|
||||
|
||||
export interface PaymentHistoryProps {
|
||||
payments: PaymentRecord[]
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
confirmed: 'bg-green-600/20 text-green-500',
|
||||
pending: 'bg-yellow-600/20 text-yellow-500',
|
||||
failed: 'bg-red-600/20 text-red-400',
|
||||
}
|
||||
|
||||
export function PaymentHistory({ payments }: PaymentHistoryProps) {
|
||||
if (payments.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
|
||||
No payments recorded yet.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Date</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Amount</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Tx Hash</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payments.map((p) => (
|
||||
<tr key={p.id} className="border-b border-border last:border-0 hover:bg-muted/20">
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{new Date(p.paidAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono">{p.amount}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusColors[p.status] ?? ''}`}>
|
||||
{p.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
<span className="truncate block max-w-[200px]">{p.txHash}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
const chains = [
|
||||
{ name: 'Ethereum', chainId: 1 },
|
||||
{ name: 'Lux C-Chain', chainId: 96369 },
|
||||
{ name: 'Lux Testnet', chainId: 96368 },
|
||||
{ name: 'Zoo', chainId: 200200 },
|
||||
{ name: 'Hanzo', chainId: 36963 },
|
||||
]
|
||||
|
||||
const intervals = ['daily', 'weekly', 'monthly', 'quarterly', 'yearly']
|
||||
|
||||
export interface SubscriptionFormData {
|
||||
name: string
|
||||
provider: string
|
||||
walletId: string
|
||||
recipientAddress: string
|
||||
chain: string
|
||||
chainId: number
|
||||
amount: string
|
||||
token: string
|
||||
interval: string
|
||||
}
|
||||
|
||||
export interface SubscriptionFormProps {
|
||||
walletId?: string
|
||||
onSubmit: (data: SubscriptionFormData) => void
|
||||
onCancel: () => void
|
||||
submitting?: boolean
|
||||
}
|
||||
|
||||
export function SubscriptionForm({
|
||||
walletId = '',
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitting = false,
|
||||
}: SubscriptionFormProps) {
|
||||
const [form, setForm] = useState<SubscriptionFormData>({
|
||||
name: '',
|
||||
provider: '',
|
||||
walletId,
|
||||
recipientAddress: '',
|
||||
chain: chains[0].name,
|
||||
chainId: chains[0].chainId,
|
||||
amount: '',
|
||||
token: 'USDC',
|
||||
interval: 'monthly',
|
||||
})
|
||||
|
||||
function update<K extends keyof SubscriptionFormData>(
|
||||
field: K,
|
||||
value: SubscriptionFormData[K]
|
||||
) {
|
||||
setForm((f) => ({ ...f, [field]: value }))
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
onSubmit(form)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="sub-name">
|
||||
Subscription Name
|
||||
</label>
|
||||
<input
|
||||
id="sub-name"
|
||||
type="text"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
placeholder="e.g. Cloud Hosting"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="sub-provider">
|
||||
Provider
|
||||
</label>
|
||||
<input
|
||||
id="sub-provider"
|
||||
type="text"
|
||||
required
|
||||
value={form.provider}
|
||||
onChange={(e) => update('provider', e.target.value)}
|
||||
placeholder="Provider name"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!walletId && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="sub-wallet">
|
||||
Wallet ID
|
||||
</label>
|
||||
<input
|
||||
id="sub-wallet"
|
||||
type="text"
|
||||
required
|
||||
value={form.walletId}
|
||||
onChange={(e) => update('walletId', e.target.value)}
|
||||
placeholder="wallet-..."
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="sub-recipient">
|
||||
Recipient Address
|
||||
</label>
|
||||
<input
|
||||
id="sub-recipient"
|
||||
type="text"
|
||||
required
|
||||
value={form.recipientAddress}
|
||||
onChange={(e) => update('recipientAddress', e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="sub-chain">
|
||||
Chain
|
||||
</label>
|
||||
<select
|
||||
id="sub-chain"
|
||||
value={form.chainId}
|
||||
onChange={(e) => {
|
||||
const chain = chains.find((c) => c.chainId === Number(e.target.value))
|
||||
if (chain) {
|
||||
setForm((f) => ({ ...f, chain: chain.name, chainId: chain.chainId }))
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{chains.map((c) => (
|
||||
<option key={c.chainId} value={c.chainId}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="sub-interval">
|
||||
Interval
|
||||
</label>
|
||||
<select
|
||||
id="sub-interval"
|
||||
value={form.interval}
|
||||
onChange={(e) => update('interval', e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{intervals.map((i) => (
|
||||
<option key={i} value={i}>
|
||||
{i.charAt(0).toUpperCase() + i.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="sub-amount">
|
||||
Amount
|
||||
</label>
|
||||
<input
|
||||
id="sub-amount"
|
||||
type="text"
|
||||
required
|
||||
value={form.amount}
|
||||
onChange={(e) => update('amount', e.target.value)}
|
||||
placeholder="e.g. 100"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="sub-token">
|
||||
Token
|
||||
</label>
|
||||
<input
|
||||
id="sub-token"
|
||||
type="text"
|
||||
required
|
||||
value={form.token}
|
||||
onChange={(e) => update('token', e.target.value)}
|
||||
placeholder="e.g. USDC, ETH"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !form.name || !form.recipientAddress || !form.amount}
|
||||
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create Subscription'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export interface SubscriptionRow {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
chain: string
|
||||
amount: string
|
||||
token: string
|
||||
interval: string
|
||||
status: 'active' | 'paused' | 'cancelled'
|
||||
nextPaymentAt: string
|
||||
}
|
||||
|
||||
export interface SubscriptionTableProps {
|
||||
subscriptions: SubscriptionRow[]
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
active: 'bg-green-600/20 text-green-500',
|
||||
paused: 'bg-yellow-600/20 text-yellow-500',
|
||||
cancelled: 'bg-zinc-600/20 text-zinc-400',
|
||||
}
|
||||
|
||||
export function SubscriptionTable({ subscriptions }: SubscriptionTableProps) {
|
||||
if (subscriptions.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
|
||||
No subscriptions found.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Provider</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Amount</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Interval</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Next Payment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subscriptions.map((sub) => (
|
||||
<tr key={sub.id} className="border-b border-border last:border-0 hover:bg-muted/20">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/subscriptions/${sub.id}`} className="font-medium hover:underline">
|
||||
{sub.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{sub.provider}</td>
|
||||
<td className="px-4 py-3">{sub.chain}</td>
|
||||
<td className="px-4 py-3 text-right font-mono">
|
||||
{sub.amount} {sub.token}
|
||||
</td>
|
||||
<td className="px-4 py-3 capitalize">{sub.interval}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusColors[sub.status] ?? ''}`}>
|
||||
{sub.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-muted-foreground">
|
||||
{sub.status !== 'cancelled'
|
||||
? new Date(sub.nextPaymentAt).toLocaleDateString()
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export interface ApprovalPanelProps {
|
||||
transactionId: string
|
||||
status: 'awaiting_approval' | 'pending' | 'confirmed' | 'failed'
|
||||
requiredApprovals: number
|
||||
currentApprovals: number
|
||||
canApprove: boolean
|
||||
onApprove: (transactionId: string) => Promise<void>
|
||||
onReject: (transactionId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function ApprovalPanel({
|
||||
transactionId,
|
||||
status,
|
||||
requiredApprovals,
|
||||
currentApprovals,
|
||||
canApprove,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: ApprovalPanelProps) {
|
||||
const [acting, setActing] = useState(false)
|
||||
|
||||
async function handleApprove() {
|
||||
setActing(true)
|
||||
try {
|
||||
await onApprove(transactionId)
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
setActing(true)
|
||||
try {
|
||||
await onReject(transactionId)
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (status !== 'awaiting_approval') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Approvals</span>
|
||||
<span className="font-mono">
|
||||
{currentApprovals} / {requiredApprovals}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-primary transition-all"
|
||||
style={{
|
||||
width: `${Math.min(100, (currentApprovals / requiredApprovals) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canApprove && (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleReject}
|
||||
disabled={acting}
|
||||
className="flex-1 rounded-md border border-destructive/30 px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={acting}
|
||||
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{acting ? 'Submitting...' : 'Approve'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canApprove && (
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
You have already submitted your approval for this transaction.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
export interface TxDetailData {
|
||||
id: string
|
||||
type: string
|
||||
status: string
|
||||
chain: string
|
||||
chainId: number
|
||||
from: string
|
||||
to: string
|
||||
value: string
|
||||
hash?: string
|
||||
data?: string
|
||||
nonce?: number
|
||||
gasUsed?: string
|
||||
gasPrice?: string
|
||||
blockNumber?: number
|
||||
memo?: string
|
||||
createdAt: string
|
||||
confirmedAt?: string
|
||||
approvals?: { address: string; approved: boolean; timestamp: string }[]
|
||||
}
|
||||
|
||||
export interface TxDetailProps {
|
||||
transaction: TxDetailData
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-600/20 text-yellow-500',
|
||||
confirmed: 'bg-green-600/20 text-green-500',
|
||||
failed: 'bg-red-600/20 text-red-400',
|
||||
awaiting_approval: 'bg-blue-600/20 text-blue-400',
|
||||
}
|
||||
|
||||
export function TxDetail({ transaction: tx }: TxDetailProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium ${statusColors[tx.status] ?? 'bg-muted text-muted-foreground'}`}
|
||||
>
|
||||
{tx.status.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{tx.type}</span>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Chain</dt>
|
||||
<dd className="mt-1">
|
||||
{tx.chain} ({tx.chainId})
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Transaction ID</dt>
|
||||
<dd className="mt-1 font-mono text-xs">{tx.id}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-muted-foreground">From</dt>
|
||||
<dd className="mt-1 truncate font-mono text-xs">{tx.from}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-muted-foreground">To</dt>
|
||||
<dd className="mt-1 truncate font-mono text-xs">{tx.to}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Value</dt>
|
||||
<dd className="mt-1 font-mono">{tx.value}</dd>
|
||||
</div>
|
||||
{tx.hash && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Tx Hash</dt>
|
||||
<dd className="mt-1 truncate font-mono text-xs">{tx.hash}</dd>
|
||||
</div>
|
||||
)}
|
||||
{tx.nonce != null && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Nonce</dt>
|
||||
<dd className="mt-1 font-mono">{tx.nonce}</dd>
|
||||
</div>
|
||||
)}
|
||||
{tx.blockNumber != null && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Block</dt>
|
||||
<dd className="mt-1 font-mono">{tx.blockNumber}</dd>
|
||||
</div>
|
||||
)}
|
||||
{tx.gasUsed && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Gas Used</dt>
|
||||
<dd className="mt-1 font-mono">{tx.gasUsed}</dd>
|
||||
</div>
|
||||
)}
|
||||
{tx.gasPrice && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Gas Price</dt>
|
||||
<dd className="mt-1 font-mono">{tx.gasPrice}</dd>
|
||||
</div>
|
||||
)}
|
||||
{tx.data && (
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-muted-foreground">Calldata</dt>
|
||||
<dd className="mt-1 break-all font-mono text-xs">{tx.data}</dd>
|
||||
</div>
|
||||
)}
|
||||
{tx.memo && (
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-muted-foreground">Memo</dt>
|
||||
<dd className="mt-1">{tx.memo}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Created</dt>
|
||||
<dd className="mt-1">{new Date(tx.createdAt).toLocaleString()}</dd>
|
||||
</div>
|
||||
{tx.confirmedAt && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Confirmed</dt>
|
||||
<dd className="mt-1">{new Date(tx.confirmedAt).toLocaleString()}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{tx.approvals && tx.approvals.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Approvals</h3>
|
||||
<div className="space-y-1">
|
||||
{tx.approvals.map((a) => (
|
||||
<div
|
||||
key={a.address}
|
||||
className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-xs"
|
||||
>
|
||||
<span className="truncate font-mono">{a.address}</span>
|
||||
<span className={a.approved ? 'text-green-500' : 'text-yellow-500'}>
|
||||
{a.approved ? 'Approved' : 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
const chains = [
|
||||
{ name: 'Ethereum', chainId: 1 },
|
||||
{ name: 'Lux C-Chain', chainId: 96369 },
|
||||
{ name: 'Lux Testnet', chainId: 96368 },
|
||||
{ name: 'Zoo', chainId: 200200 },
|
||||
{ name: 'Hanzo', chainId: 36963 },
|
||||
{ name: 'Bitcoin', chainId: 0 },
|
||||
]
|
||||
|
||||
export interface TxFormData {
|
||||
walletId: string
|
||||
chain: string
|
||||
chainId: number
|
||||
to: string
|
||||
value: string
|
||||
data: string
|
||||
memo: string
|
||||
}
|
||||
|
||||
export interface TxFormProps {
|
||||
walletId?: string
|
||||
onSubmit: (data: TxFormData) => void
|
||||
onCancel: () => void
|
||||
submitting?: boolean
|
||||
}
|
||||
|
||||
export function TxForm({ walletId = '', onSubmit, onCancel, submitting = false }: TxFormProps) {
|
||||
const [form, setForm] = useState<TxFormData>({
|
||||
walletId,
|
||||
chain: chains[0].name,
|
||||
chainId: chains[0].chainId,
|
||||
to: '',
|
||||
value: '',
|
||||
data: '',
|
||||
memo: '',
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
onSubmit(form)
|
||||
}
|
||||
|
||||
function update(field: keyof TxFormData, value: string | number) {
|
||||
setForm((f) => ({ ...f, [field]: value }))
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{!walletId && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="tx-wallet">
|
||||
Wallet ID
|
||||
</label>
|
||||
<input
|
||||
id="tx-wallet"
|
||||
type="text"
|
||||
required
|
||||
value={form.walletId}
|
||||
onChange={(e) => update('walletId', e.target.value)}
|
||||
placeholder="wallet-..."
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="tx-chain">
|
||||
Chain
|
||||
</label>
|
||||
<select
|
||||
id="tx-chain"
|
||||
value={form.chainId}
|
||||
onChange={(e) => {
|
||||
const chain = chains.find((c) => c.chainId === Number(e.target.value))
|
||||
if (chain) {
|
||||
setForm((f) => ({ ...f, chain: chain.name, chainId: chain.chainId }))
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{chains.map((c) => (
|
||||
<option key={c.chainId} value={c.chainId}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="tx-to">
|
||||
Recipient Address
|
||||
</label>
|
||||
<input
|
||||
id="tx-to"
|
||||
type="text"
|
||||
required
|
||||
value={form.to}
|
||||
onChange={(e) => update('to', e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="tx-value">
|
||||
Value
|
||||
</label>
|
||||
<input
|
||||
id="tx-value"
|
||||
type="text"
|
||||
required
|
||||
value={form.value}
|
||||
onChange={(e) => update('value', e.target.value)}
|
||||
placeholder="e.g. 1.5"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="tx-data">
|
||||
Calldata (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="tx-data"
|
||||
value={form.data}
|
||||
onChange={(e) => update('data', e.target.value)}
|
||||
placeholder="0x..."
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="tx-memo">
|
||||
Memo
|
||||
</label>
|
||||
<input
|
||||
id="tx-memo"
|
||||
type="text"
|
||||
value={form.memo}
|
||||
onChange={(e) => update('memo', e.target.value)}
|
||||
placeholder="Optional note"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !form.to || !form.value}
|
||||
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Create Transaction'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export interface Transaction {
|
||||
id: string
|
||||
type: string
|
||||
status: 'pending' | 'confirmed' | 'failed' | 'awaiting_approval'
|
||||
chain: string
|
||||
from: string
|
||||
to: string
|
||||
value: string
|
||||
hash?: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface TxTableProps {
|
||||
transactions: Transaction[]
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-600/20 text-yellow-500',
|
||||
confirmed: 'bg-green-600/20 text-green-500',
|
||||
failed: 'bg-red-600/20 text-red-400',
|
||||
awaiting_approval: 'bg-blue-600/20 text-blue-400',
|
||||
}
|
||||
|
||||
export function TxTable({ transactions }: TxTableProps) {
|
||||
if (transactions.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
|
||||
No transactions found.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Type</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">From</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">To</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Value</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.map((tx) => (
|
||||
<tr key={tx.id} className="border-b border-border last:border-0 hover:bg-muted/20">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/transactions/${tx.id}`} className="font-medium hover:underline">
|
||||
{tx.type}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusColors[tx.status] ?? ''}`}>
|
||||
{tx.status.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">{tx.chain}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
<span className="truncate block max-w-[120px]">{tx.from}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
<span className="truncate block max-w-[120px]">{tx.to}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono">{tx.value}</td>
|
||||
<td className="px-4 py-3 text-right text-muted-foreground">
|
||||
{new Date(tx.timestamp).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
export interface VaultCardProps {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
walletCount: number
|
||||
}
|
||||
|
||||
export function VaultCard({ id, name, description, walletCount }: VaultCardProps) {
|
||||
return (
|
||||
<Link
|
||||
href={`/vaults/${id}`}
|
||||
className="block rounded-lg border border-border bg-card p-6 transition-colors hover:border-muted-foreground/30 hover:bg-accent/50"
|
||||
>
|
||||
<h3 className="text-sm font-semibold">{name}</h3>
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{walletCount} wallet{walletCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export interface VaultFormProps {
|
||||
initialName?: string
|
||||
initialDescription?: string
|
||||
initialAppId?: string
|
||||
onSubmit: (data: { name: string; description: string; appId: string }) => void
|
||||
onCancel: () => void
|
||||
submitting?: boolean
|
||||
}
|
||||
|
||||
export function VaultForm({
|
||||
initialName = '',
|
||||
initialDescription = '',
|
||||
initialAppId = '',
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitting = false,
|
||||
}: VaultFormProps) {
|
||||
const [name, setName] = useState(initialName)
|
||||
const [description, setDescription] = useState(initialDescription)
|
||||
const [appId, setAppId] = useState(initialAppId)
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
onSubmit({ name, description, appId })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="vault-name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="vault-name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Vault name"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="vault-desc">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="vault-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="vault-app-id">
|
||||
App ID
|
||||
</label>
|
||||
<input
|
||||
id="vault-app-id"
|
||||
type="text"
|
||||
value={appId}
|
||||
onChange={(e) => setAppId(e.target.value)}
|
||||
placeholder="Application identifier"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !name}
|
||||
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface ChainAddress {
|
||||
chain: string
|
||||
chainId?: number
|
||||
address: string
|
||||
format?: string
|
||||
}
|
||||
|
||||
export interface AddressListProps {
|
||||
addresses: ChainAddress[]
|
||||
}
|
||||
|
||||
export function AddressList({ addresses }: AddressListProps) {
|
||||
if (addresses.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">No derived addresses.</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{addresses.map((a) => (
|
||||
<div
|
||||
key={`${a.chain}-${a.address}`}
|
||||
className="flex items-center justify-between rounded-md border border-border bg-muted/30 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium">
|
||||
{a.chain}
|
||||
{a.chainId != null && (
|
||||
<span className="ml-1 text-muted-foreground">({a.chainId})</span>
|
||||
)}
|
||||
{a.format && (
|
||||
<span className="ml-2 text-muted-foreground">[{a.format}]</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||||
{a.address}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(a.address)}
|
||||
className="ml-2 shrink-0 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface Balance {
|
||||
chain: string
|
||||
token: string
|
||||
amount: string
|
||||
usdValue?: string
|
||||
}
|
||||
|
||||
export interface BalanceDisplayProps {
|
||||
balances: Balance[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function BalanceDisplay({ balances, loading = false }: BalanceDisplayProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-10 animate-pulse rounded-md bg-muted"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (balances.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">No balances found.</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{balances.map((b) => (
|
||||
<div
|
||||
key={`${b.chain}-${b.token}`}
|
||||
className="flex items-center justify-between rounded-md px-3 py-2 text-sm hover:bg-muted/30"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">{b.token}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{b.chain}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-mono">{b.amount}</span>
|
||||
{b.usdValue && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
${b.usdValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
export interface KeygenProgressProps {
|
||||
status: 'waiting' | 'round1' | 'round2' | 'round3' | 'round4' | 'finalizing' | 'complete' | 'error'
|
||||
protocol: 'cggmp21' | 'frost' | 'lss'
|
||||
participants: number
|
||||
readyCount?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
const roundLabels: Record<string, string[]> = {
|
||||
cggmp21: ['Waiting for peers', 'Round 1: Commitments', 'Round 2: Shares', 'Round 3: Proofs', 'Round 4: Verification', 'Finalizing', 'Complete'],
|
||||
frost: ['Waiting for peers', 'Round 1: Commitments', 'Round 2: Shares', 'Verification', '', 'Finalizing', 'Complete'],
|
||||
lss: ['Waiting for peers', 'Round 1: Commitments', 'Round 2: Share Distribution', 'Round 3: Verification', '', 'Finalizing', 'Complete'],
|
||||
}
|
||||
|
||||
const statusToIndex: Record<string, number> = {
|
||||
waiting: 0,
|
||||
round1: 1,
|
||||
round2: 2,
|
||||
round3: 3,
|
||||
round4: 4,
|
||||
finalizing: 5,
|
||||
complete: 6,
|
||||
error: -1,
|
||||
}
|
||||
|
||||
export function KeygenProgress({
|
||||
status,
|
||||
protocol,
|
||||
participants,
|
||||
readyCount = 0,
|
||||
error,
|
||||
}: KeygenProgressProps) {
|
||||
const labels = roundLabels[protocol] ?? roundLabels.cggmp21
|
||||
const currentIndex = statusToIndex[status] ?? 0
|
||||
const totalSteps = labels.filter(Boolean).length
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-6 text-center">
|
||||
<div className="mx-auto mb-3 h-8 w-8 rounded-full border-2 border-destructive flex items-center justify-center text-destructive text-sm font-bold">
|
||||
!
|
||||
</div>
|
||||
<p className="text-sm font-medium text-destructive">Key Generation Failed</p>
|
||||
{error && <p className="mt-1 text-xs text-destructive/80">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-6 space-y-4">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
{status !== 'complete' && (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{status === 'complete'
|
||||
? 'Key Generation Complete'
|
||||
: labels[currentIndex] || 'Processing...'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-2 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="h-2 rounded-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${Math.max(5, (currentIndex / (totalSteps - 1)) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
{labels.filter(Boolean).map((label, i) => (
|
||||
<span
|
||||
key={label}
|
||||
className={i <= currentIndex ? 'text-foreground' : ''}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{status === 'waiting' && (
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{readyCount} of {participants} participants ready
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
export interface ReshareWizardProps {
|
||||
step: 'configuring' | 'confirming' | 'resharing' | 'complete' | 'error'
|
||||
threshold: number
|
||||
participants: string[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function ReshareWizard({ step, threshold, participants, error }: ReshareWizardProps) {
|
||||
const steps = ['Configure', 'Confirm', 'Reshare', 'Complete']
|
||||
const stepIndex = { configuring: 0, confirming: 1, resharing: 2, complete: 3, error: 2 }
|
||||
|
||||
const current = stepIndex[step]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step indicators */}
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((label, i) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium ${
|
||||
i < current
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: i === current
|
||||
? 'border-2 border-primary text-primary'
|
||||
: 'border border-border text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{i < current ? '\u2713' : i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={`hidden text-xs sm:inline ${
|
||||
i <= current ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{i < steps.length - 1 && (
|
||||
<div
|
||||
className={`mx-2 hidden h-px w-8 sm:block ${
|
||||
i < current ? 'bg-primary' : 'bg-border'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reshare progress */}
|
||||
{step === 'resharing' && (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-center space-y-4">
|
||||
<div className="mx-auto h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<p className="text-sm font-medium">Resharing Key Shares</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Rotating to {threshold}-of-{participants.length} configuration.
|
||||
All participants must remain online.
|
||||
</p>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{participants.map((p) => (
|
||||
<p key={p} className="font-mono">{p}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'error' && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-6 text-center">
|
||||
<p className="text-sm font-medium text-destructive">Reshare Failed</p>
|
||||
{error && <p className="mt-1 text-xs text-destructive/80">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
export interface WalletCardProps {
|
||||
id: string
|
||||
name: string
|
||||
threshold: number
|
||||
participants: number
|
||||
ecdsaPubkey: string
|
||||
eddsaPubkey?: string
|
||||
protocol: 'cggmp21' | 'frost' | 'lss'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function WalletCard({
|
||||
id,
|
||||
name,
|
||||
threshold,
|
||||
participants,
|
||||
ecdsaPubkey,
|
||||
eddsaPubkey,
|
||||
protocol,
|
||||
createdAt,
|
||||
}: WalletCardProps) {
|
||||
const protocolLabel: Record<string, string> = {
|
||||
cggmp21: 'CGGMP21',
|
||||
frost: 'FROST',
|
||||
lss: 'LSS',
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/wallets/${id}`}
|
||||
className="block rounded-lg border border-border bg-card p-6 transition-colors hover:border-muted-foreground/30 hover:bg-accent/50"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-sm font-semibold">{name}</h3>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{protocolLabel[protocol] ?? protocol}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 font-mono text-xs text-muted-foreground">
|
||||
{threshold}-of-{participants}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="truncate font-mono text-xs text-muted-foreground" title={ecdsaPubkey}>
|
||||
ECDSA: {ecdsaPubkey}
|
||||
</p>
|
||||
{eddsaPubkey && (
|
||||
<p className="truncate font-mono text-xs text-muted-foreground" title={eddsaPubkey}>
|
||||
EdDSA: {eddsaPubkey}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Created {new Date(createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
// API client for Lux MPC backend (all routes from pkg/api/server.go)
|
||||
|
||||
import { getToken, getRefreshToken, setTokens, clearTokens } from './auth'
|
||||
import type {
|
||||
AuthResponse,
|
||||
RegisterRequest,
|
||||
LoginRequest,
|
||||
MFARequiredResponse,
|
||||
Vault,
|
||||
CreateVaultRequest,
|
||||
UpdateVaultRequest,
|
||||
Wallet,
|
||||
WalletAddresses,
|
||||
CreateWalletRequest,
|
||||
ReshareWalletRequest,
|
||||
Transaction,
|
||||
TransactionFilters,
|
||||
CreateTransactionRequest,
|
||||
RejectTransactionRequest,
|
||||
Policy,
|
||||
CreatePolicyRequest,
|
||||
UpdatePolicyRequest,
|
||||
AddressWhitelist,
|
||||
AddWhitelistRequest,
|
||||
Webhook,
|
||||
CreateWebhookRequest,
|
||||
UpdateWebhookRequest,
|
||||
Subscription,
|
||||
CreateSubscriptionRequest,
|
||||
UpdateSubscriptionRequest,
|
||||
PaymentRequest,
|
||||
CreatePaymentRequestRequest,
|
||||
PayPaymentRequestRequest,
|
||||
SmartWallet,
|
||||
DeploySmartWalletRequest,
|
||||
ProposeSafeTxRequest,
|
||||
ExecuteSafeTxRequest,
|
||||
UserOperationRequest,
|
||||
User,
|
||||
InviteUserRequest,
|
||||
UpdateUserRequest,
|
||||
APIKey,
|
||||
APIKeyCreateResponse,
|
||||
CreateAPIKeyRequest,
|
||||
AuditEntry,
|
||||
ClusterStatus,
|
||||
InfoResponse,
|
||||
} from './types'
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public body: { error: string },
|
||||
) {
|
||||
super(body.error)
|
||||
this.name = 'APIError'
|
||||
}
|
||||
}
|
||||
|
||||
export class APIClient {
|
||||
private baseURL: string
|
||||
private refreshing: Promise<void> | null = null
|
||||
|
||||
constructor(baseURL?: string) {
|
||||
this.baseURL = baseURL ?? (process.env.NEXT_PUBLIC_API_URL || '/api/v1')
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
retry = true,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const res = await fetch(`${this.baseURL}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
// Handle 401 with automatic token refresh
|
||||
if (res.status === 401 && retry) {
|
||||
await this.tryRefresh()
|
||||
return this.request<T>(method, path, body, false)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private async tryRefresh(): Promise<void> {
|
||||
// Deduplicate concurrent refresh attempts
|
||||
if (this.refreshing) {
|
||||
await this.refreshing
|
||||
return
|
||||
}
|
||||
|
||||
this.refreshing = (async () => {
|
||||
const refreshToken = getRefreshToken()
|
||||
if (!refreshToken) {
|
||||
clearTokens()
|
||||
throw new APIError(401, { error: 'no refresh token' })
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${this.baseURL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
clearTokens()
|
||||
throw new APIError(401, { error: 'refresh failed' })
|
||||
}
|
||||
|
||||
const data: AuthResponse = await res.json()
|
||||
setTokens(data.access_token, data.refresh_token)
|
||||
} catch (err) {
|
||||
clearTokens()
|
||||
throw err
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
await this.refreshing
|
||||
} finally {
|
||||
this.refreshing = null
|
||||
}
|
||||
}
|
||||
|
||||
private get<T>(path: string): Promise<T> {
|
||||
return this.request<T>('GET', path)
|
||||
}
|
||||
|
||||
private post<T>(path: string, body?: unknown): Promise<T> {
|
||||
return this.request<T>('POST', path, body)
|
||||
}
|
||||
|
||||
private patch<T>(path: string, body?: unknown): Promise<T> {
|
||||
return this.request<T>('PATCH', path, body)
|
||||
}
|
||||
|
||||
private del<T>(path: string): Promise<T> {
|
||||
return this.request<T>('DELETE', path)
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
async register(req: RegisterRequest): Promise<AuthResponse> {
|
||||
return this.post<AuthResponse>('/auth/register', req)
|
||||
}
|
||||
|
||||
async login(req: LoginRequest): Promise<AuthResponse | MFARequiredResponse> {
|
||||
return this.post<AuthResponse | MFARequiredResponse>('/auth/login', req)
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string): Promise<AuthResponse> {
|
||||
return this.post<AuthResponse>('/auth/refresh', { refresh_token: refreshToken })
|
||||
}
|
||||
|
||||
async mfaSetup(): Promise<{ secret: string; otpauth: string }> {
|
||||
return this.post('/auth/mfa/setup')
|
||||
}
|
||||
|
||||
async mfaVerify(code: string): Promise<{ status: string }> {
|
||||
return this.post('/auth/mfa/verify', { code })
|
||||
}
|
||||
|
||||
// --- Vaults ---
|
||||
|
||||
async listVaults(): Promise<Vault[]> {
|
||||
return this.get<Vault[]>('/vaults')
|
||||
}
|
||||
|
||||
async createVault(req: CreateVaultRequest): Promise<Vault> {
|
||||
return this.post<Vault>('/vaults', req)
|
||||
}
|
||||
|
||||
async getVault(id: string): Promise<Vault> {
|
||||
return this.get<Vault>(`/vaults/${id}`)
|
||||
}
|
||||
|
||||
async updateVault(id: string, req: UpdateVaultRequest): Promise<Vault> {
|
||||
return this.patch<Vault>(`/vaults/${id}`, req)
|
||||
}
|
||||
|
||||
async deleteVault(id: string): Promise<void> {
|
||||
return this.del(`/vaults/${id}`)
|
||||
}
|
||||
|
||||
// --- Wallets ---
|
||||
|
||||
async listWallets(vaultId: string): Promise<Wallet[]> {
|
||||
return this.get<Wallet[]>(`/vaults/${vaultId}/wallets`)
|
||||
}
|
||||
|
||||
async createWallet(vaultId: string, req: CreateWalletRequest): Promise<Wallet> {
|
||||
return this.post<Wallet>(`/vaults/${vaultId}/wallets`, req)
|
||||
}
|
||||
|
||||
async getWallet(id: string): Promise<Wallet> {
|
||||
return this.get<Wallet>(`/wallets/${id}`)
|
||||
}
|
||||
|
||||
async getWalletAddresses(id: string): Promise<WalletAddresses> {
|
||||
return this.get<WalletAddresses>(`/wallets/${id}/addresses`)
|
||||
}
|
||||
|
||||
async reshareWallet(id: string, req: ReshareWalletRequest): Promise<{ status: string }> {
|
||||
return this.post(`/wallets/${id}/reshare`, req)
|
||||
}
|
||||
|
||||
async getWalletHistory(id: string): Promise<Transaction[]> {
|
||||
return this.get<Transaction[]>(`/wallets/${id}/history`)
|
||||
}
|
||||
|
||||
// --- Transactions ---
|
||||
|
||||
async createTransaction(req: CreateTransactionRequest): Promise<Transaction> {
|
||||
return this.post<Transaction>('/transactions', req)
|
||||
}
|
||||
|
||||
async listTransactions(filters?: TransactionFilters): Promise<Transaction[]> {
|
||||
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 this.get<Transaction[]>(`/transactions${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
async getTransaction(id: string): Promise<Transaction> {
|
||||
return this.get<Transaction>(`/transactions/${id}`)
|
||||
}
|
||||
|
||||
async approveTransaction(id: string): Promise<{ status: string }> {
|
||||
return this.post(`/transactions/${id}/approve`)
|
||||
}
|
||||
|
||||
async rejectTransaction(id: string, req?: RejectTransactionRequest): Promise<{ status: string }> {
|
||||
return this.post(`/transactions/${id}/reject`, req)
|
||||
}
|
||||
|
||||
// --- Policies ---
|
||||
|
||||
async listPolicies(): Promise<Policy[]> {
|
||||
return this.get<Policy[]>('/policies')
|
||||
}
|
||||
|
||||
async createPolicy(req: CreatePolicyRequest): Promise<Policy> {
|
||||
return this.post<Policy>('/policies', req)
|
||||
}
|
||||
|
||||
async updatePolicy(id: string, req: UpdatePolicyRequest): Promise<Policy> {
|
||||
return this.patch<Policy>(`/policies/${id}`, req)
|
||||
}
|
||||
|
||||
async deletePolicy(id: string): Promise<void> {
|
||||
return this.del(`/policies/${id}`)
|
||||
}
|
||||
|
||||
// --- Whitelist ---
|
||||
|
||||
async listWhitelist(): Promise<AddressWhitelist[]> {
|
||||
return this.get<AddressWhitelist[]>('/whitelist')
|
||||
}
|
||||
|
||||
async addWhitelist(req: AddWhitelistRequest): Promise<AddressWhitelist> {
|
||||
return this.post<AddressWhitelist>('/whitelist', req)
|
||||
}
|
||||
|
||||
async deleteWhitelist(id: string): Promise<void> {
|
||||
return this.del(`/whitelist/${id}`)
|
||||
}
|
||||
|
||||
// --- Webhooks ---
|
||||
|
||||
async listWebhooks(): Promise<Webhook[]> {
|
||||
return this.get<Webhook[]>('/webhooks')
|
||||
}
|
||||
|
||||
async createWebhook(req: CreateWebhookRequest): Promise<Webhook> {
|
||||
return this.post<Webhook>('/webhooks', req)
|
||||
}
|
||||
|
||||
async updateWebhook(id: string, req: UpdateWebhookRequest): Promise<Webhook> {
|
||||
return this.patch<Webhook>(`/webhooks/${id}`, req)
|
||||
}
|
||||
|
||||
async deleteWebhook(id: string): Promise<void> {
|
||||
return this.del(`/webhooks/${id}`)
|
||||
}
|
||||
|
||||
async testWebhook(id: string): Promise<{ status: string }> {
|
||||
return this.post(`/webhooks/${id}/test`)
|
||||
}
|
||||
|
||||
// --- Subscriptions ---
|
||||
|
||||
async listSubscriptions(): Promise<Subscription[]> {
|
||||
return this.get<Subscription[]>('/subscriptions')
|
||||
}
|
||||
|
||||
async createSubscription(req: CreateSubscriptionRequest): Promise<Subscription> {
|
||||
return this.post<Subscription>('/subscriptions', req)
|
||||
}
|
||||
|
||||
async getSubscription(id: string): Promise<Subscription> {
|
||||
return this.get<Subscription>(`/subscriptions/${id}`)
|
||||
}
|
||||
|
||||
async updateSubscription(id: string, req: UpdateSubscriptionRequest): Promise<Subscription> {
|
||||
return this.patch<Subscription>(`/subscriptions/${id}`, req)
|
||||
}
|
||||
|
||||
async deleteSubscription(id: string): Promise<void> {
|
||||
return this.del(`/subscriptions/${id}`)
|
||||
}
|
||||
|
||||
async payNow(id: string): Promise<{ status: string; tx_id: string }> {
|
||||
return this.post(`/subscriptions/${id}/pay-now`)
|
||||
}
|
||||
|
||||
// --- Payment Requests ---
|
||||
|
||||
async createPaymentRequest(req: CreatePaymentRequestRequest): Promise<{
|
||||
payment_request: PaymentRequest
|
||||
payment_url: string
|
||||
}> {
|
||||
return this.post('/payment-requests', req)
|
||||
}
|
||||
|
||||
async listPaymentRequests(): Promise<PaymentRequest[]> {
|
||||
return this.get<PaymentRequest[]>('/payment-requests')
|
||||
}
|
||||
|
||||
async getPaymentRequest(id: string): Promise<PaymentRequest> {
|
||||
return this.get<PaymentRequest>(`/payment-requests/${id}`)
|
||||
}
|
||||
|
||||
async payPaymentRequest(id: string, req: PayPaymentRequestRequest): Promise<{ status: string; tx_id: string }> {
|
||||
return this.post(`/payment-requests/${id}/pay`, req)
|
||||
}
|
||||
|
||||
// --- Smart Wallets ---
|
||||
|
||||
async deploySmartWallet(walletId: string, req: DeploySmartWalletRequest): Promise<SmartWallet> {
|
||||
return this.post<SmartWallet>(`/wallets/${walletId}/smart-wallet`, req)
|
||||
}
|
||||
|
||||
async listSmartWallets(walletId: string): Promise<SmartWallet[]> {
|
||||
return this.get<SmartWallet[]>(`/wallets/${walletId}/smart-wallets`)
|
||||
}
|
||||
|
||||
async getSmartWallet(id: string): Promise<SmartWallet> {
|
||||
return this.get<SmartWallet>(`/smart-wallets/${id}`)
|
||||
}
|
||||
|
||||
async proposeSafeTx(id: string, req: ProposeSafeTxRequest): Promise<{ status: string; smart_wallet_id: string; to: string; value: string }> {
|
||||
return this.post(`/smart-wallets/${id}/propose`, req)
|
||||
}
|
||||
|
||||
async executeSafeTx(id: string, req: ExecuteSafeTxRequest): Promise<{ status: string; safe_tx_hash: string }> {
|
||||
return this.post(`/smart-wallets/${id}/execute`, req)
|
||||
}
|
||||
|
||||
async userOperation(id: string, req: UserOperationRequest): Promise<{ status: string; smart_wallet_id: string }> {
|
||||
return this.post(`/smart-wallets/${id}/user-operation`, req)
|
||||
}
|
||||
|
||||
// --- Users & Teams ---
|
||||
|
||||
async listUsers(): Promise<User[]> {
|
||||
return this.get<User[]>('/users')
|
||||
}
|
||||
|
||||
async inviteUser(req: InviteUserRequest): Promise<User> {
|
||||
return this.post<User>('/users', req)
|
||||
}
|
||||
|
||||
async updateUser(id: string, req: UpdateUserRequest): Promise<User> {
|
||||
return this.patch<User>(`/users/${id}`, req)
|
||||
}
|
||||
|
||||
async deleteUser(id: string): Promise<void> {
|
||||
return this.del(`/users/${id}`)
|
||||
}
|
||||
|
||||
// --- API Keys ---
|
||||
|
||||
async listAPIKeys(): Promise<APIKey[]> {
|
||||
return this.get<APIKey[]>('/api-keys')
|
||||
}
|
||||
|
||||
async createAPIKey(req: CreateAPIKeyRequest): Promise<APIKeyCreateResponse> {
|
||||
return this.post<APIKeyCreateResponse>('/api-keys', req)
|
||||
}
|
||||
|
||||
async deleteAPIKey(id: string): Promise<void> {
|
||||
return this.del(`/api-keys/${id}`)
|
||||
}
|
||||
|
||||
// --- Audit ---
|
||||
|
||||
async listAudit(): Promise<AuditEntry[]> {
|
||||
return this.get<AuditEntry[]>('/audit')
|
||||
}
|
||||
|
||||
// --- Status & Info ---
|
||||
|
||||
async getStatus(): Promise<ClusterStatus> {
|
||||
return this.get<ClusterStatus>('/status')
|
||||
}
|
||||
|
||||
async getInfo(): Promise<InfoResponse> {
|
||||
return this.get<InfoResponse>('/info')
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const api = new APIClient()
|
||||
@@ -0,0 +1,30 @@
|
||||
// JWT token management via localStorage
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'lux_mpc_access_token'
|
||||
const REFRESH_TOKEN_KEY = 'lux_mpc_refresh_token'
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setTokens(accessToken: string, refreshToken: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return getToken() !== null
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { setTokens, clearTokens } from '@/lib/auth'
|
||||
import type { LoginRequest, RegisterRequest } from '@/lib/types'
|
||||
|
||||
export function useLogin() {
|
||||
return useMutation({
|
||||
mutationFn: (req: LoginRequest) => api.login(req),
|
||||
onSuccess: (data) => {
|
||||
if ('access_token' in data) {
|
||||
setTokens(data.access_token, data.refresh_token)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRegister() {
|
||||
return useMutation({
|
||||
mutationFn: (req: RegisterRequest) => api.register(req),
|
||||
onSuccess: (data) => {
|
||||
setTokens(data.access_token, data.refresh_token)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
clearTokens()
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import type { CreatePolicyRequest, UpdatePolicyRequest } from '@/lib/types'
|
||||
|
||||
const POLICIES_KEY = ['policies'] as const
|
||||
|
||||
export function usePolicies() {
|
||||
return useQuery({
|
||||
queryKey: POLICIES_KEY,
|
||||
queryFn: () => api.listPolicies(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreatePolicy() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (req: CreatePolicyRequest) => api.createPolicy(req),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: POLICIES_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdatePolicy() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...req }: UpdatePolicyRequest & { id: string }) =>
|
||||
api.updatePolicy(id, req),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: POLICIES_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeletePolicy() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deletePolicy(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: POLICIES_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import type { TransactionFilters, CreateTransactionRequest, RejectTransactionRequest } from '@/lib/types'
|
||||
|
||||
const TX_KEY = ['transactions'] as const
|
||||
|
||||
export function useTransactions(filters?: TransactionFilters) {
|
||||
return useQuery({
|
||||
queryKey: [...TX_KEY, filters ?? {}],
|
||||
queryFn: () => api.listTransactions(filters),
|
||||
})
|
||||
}
|
||||
|
||||
export function useTransaction(id: string) {
|
||||
return useQuery({
|
||||
queryKey: [...TX_KEY, id],
|
||||
queryFn: () => api.getTransaction(id),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateTransaction() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (req: CreateTransactionRequest) => api.createTransaction(req),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: TX_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useApproveTransaction() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.approveTransaction(id),
|
||||
onSuccess: (_data, id) => {
|
||||
qc.invalidateQueries({ queryKey: TX_KEY })
|
||||
qc.invalidateQueries({ queryKey: [...TX_KEY, id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRejectTransaction() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...req }: RejectTransactionRequest & { id: string }) =>
|
||||
api.rejectTransaction(id, req),
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: TX_KEY })
|
||||
qc.invalidateQueries({ queryKey: [...TX_KEY, variables.id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import type { CreateVaultRequest, UpdateVaultRequest } from '@/lib/types'
|
||||
|
||||
const VAULTS_KEY = ['vaults'] as const
|
||||
|
||||
export function useVaults() {
|
||||
return useQuery({
|
||||
queryKey: VAULTS_KEY,
|
||||
queryFn: () => api.listVaults(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useVault(id: string) {
|
||||
return useQuery({
|
||||
queryKey: [...VAULTS_KEY, id],
|
||||
queryFn: () => api.getVault(id),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateVault() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (req: CreateVaultRequest) => api.createVault(req),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: VAULTS_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateVault() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...req }: UpdateVaultRequest & { id: string }) =>
|
||||
api.updateVault(id, req),
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: VAULTS_KEY })
|
||||
qc.invalidateQueries({ queryKey: [...VAULTS_KEY, variables.id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteVault() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteVault(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: VAULTS_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import type { CreateWalletRequest, ReshareWalletRequest } from '@/lib/types'
|
||||
|
||||
const WALLETS_KEY = ['wallets'] as const
|
||||
|
||||
export function useWallets(vaultId: string) {
|
||||
return useQuery({
|
||||
queryKey: [...WALLETS_KEY, 'by-vault', vaultId],
|
||||
queryFn: () => api.listWallets(vaultId),
|
||||
enabled: !!vaultId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useWallet(id: string) {
|
||||
return useQuery({
|
||||
queryKey: [...WALLETS_KEY, id],
|
||||
queryFn: () => api.getWallet(id),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useWalletAddresses(id: string) {
|
||||
return useQuery({
|
||||
queryKey: [...WALLETS_KEY, id, 'addresses'],
|
||||
queryFn: () => api.getWalletAddresses(id),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useWalletHistory(id: string) {
|
||||
return useQuery({
|
||||
queryKey: [...WALLETS_KEY, id, 'history'],
|
||||
queryFn: () => api.getWalletHistory(id),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateWallet() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ vaultId, ...req }: CreateWalletRequest & { vaultId: string }) =>
|
||||
api.createWallet(vaultId, req),
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: [...WALLETS_KEY, 'by-vault', variables.vaultId] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useReshareWallet() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...req }: ReshareWalletRequest & { id: string }) =>
|
||||
api.reshareWallet(id, req),
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: [...WALLETS_KEY, variables.id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
// Types matching Go backend models (pkg/db/models.go + pkg/api/server.go)
|
||||
|
||||
// --- Core entities ---
|
||||
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
org_id: string
|
||||
email: string
|
||||
role: string // owner | admin | signer | viewer
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface APIKey {
|
||||
id: string
|
||||
org_id: string
|
||||
name: string
|
||||
key_prefix: string
|
||||
permissions: string[]
|
||||
created_at: string
|
||||
expires_at?: string | null
|
||||
last_used_at?: string | null
|
||||
}
|
||||
|
||||
export interface APIKeyCreateResponse extends APIKey {
|
||||
key: string // full key, returned only on creation
|
||||
}
|
||||
|
||||
export interface Vault {
|
||||
id: string
|
||||
org_id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
app_id?: 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 // secp256k1 | ed25519
|
||||
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 // active | resharing | archived
|
||||
created_by?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface WalletAddresses {
|
||||
ethereum?: string
|
||||
bitcoin?: string
|
||||
solana?: 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
|
||||
signature_r?: string | null
|
||||
signature_s?: string | null
|
||||
signature_eddsa?: string | null
|
||||
status: string // pending | pending_approval | approved | signing | signed | broadcast | failed | rejected
|
||||
initiated_by?: string | null
|
||||
approved_by?: string[]
|
||||
rejected_by?: string | null
|
||||
rejection_reason?: string | null
|
||||
created_at: string
|
||||
signed_at?: string | null
|
||||
broadcast_at?: string | null
|
||||
}
|
||||
|
||||
export interface Policy {
|
||||
id: string
|
||||
org_id: string
|
||||
vault_id?: string | null
|
||||
name: string
|
||||
priority: number
|
||||
action: string // approve | deny | require_approval
|
||||
conditions: Record<string, unknown>
|
||||
required_approvers: number
|
||||
approver_roles: string[]
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AddressWhitelist {
|
||||
id: string
|
||||
org_id: string
|
||||
vault_id?: string | null
|
||||
address: string
|
||||
chain: string
|
||||
label?: string | null
|
||||
created_by?: string | null
|
||||
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
|
||||
details?: Record<string, unknown> | null
|
||||
ip_address?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
id: string
|
||||
org_id: string
|
||||
url: string
|
||||
events: string[]
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: string
|
||||
org_id: string
|
||||
wallet_id?: string | null
|
||||
name: string
|
||||
provider_name?: string | null
|
||||
recipient_address: string
|
||||
chain: string
|
||||
token?: string | null
|
||||
amount: string
|
||||
currency: string
|
||||
interval: string // daily | weekly | monthly | yearly
|
||||
next_payment_at: string
|
||||
last_payment_at?: string | null
|
||||
last_tx_id?: string | null
|
||||
status: string // active | paused | cancelled | failed
|
||||
max_retries: number
|
||||
retry_count: number
|
||||
require_balance: boolean
|
||||
created_by?: string | null
|
||||
cancelled_by?: string | null
|
||||
created_at: string
|
||||
cancelled_at?: string | null
|
||||
}
|
||||
|
||||
export interface PaymentRequest {
|
||||
id: string
|
||||
org_id: string
|
||||
wallet_id?: string | null
|
||||
request_token: string
|
||||
merchant_name?: string | null
|
||||
recipient_address: string
|
||||
chain: string
|
||||
token?: string | null
|
||||
amount: string
|
||||
memo?: string | null
|
||||
status: string // pending | paid | expired | cancelled
|
||||
expires_at?: string | null
|
||||
paid_tx_id?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface SmartWallet {
|
||||
id: string
|
||||
wallet_id: string
|
||||
org_id: string
|
||||
chain: string
|
||||
contract_address: string
|
||||
wallet_type: string // safe | erc4337
|
||||
factory_address?: string | null
|
||||
entrypoint_address?: string | null
|
||||
salt?: string | null
|
||||
owners: string[]
|
||||
threshold: number
|
||||
status: string // active | deploying | archived
|
||||
deployed_at?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// --- MPC cluster types (pkg/api/server.go) ---
|
||||
|
||||
export interface ClusterStatus {
|
||||
node_id: string
|
||||
mode: string
|
||||
expected_peers: number
|
||||
connected_peers: number
|
||||
ready: boolean
|
||||
threshold: number
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface KeygenResult {
|
||||
wallet_id: string
|
||||
ecdsa_pub_key: string
|
||||
eddsa_pub_key: string
|
||||
eth_address: string
|
||||
}
|
||||
|
||||
export interface SignResult {
|
||||
r?: string
|
||||
s?: string
|
||||
signature?: string
|
||||
}
|
||||
|
||||
export interface InfoResponse {
|
||||
name: string
|
||||
version: string
|
||||
supported_chains: string[]
|
||||
key_types: string[]
|
||||
protocols: string[]
|
||||
}
|
||||
|
||||
// --- Auth request/response types ---
|
||||
|
||||
export interface RegisterRequest {
|
||||
org_name: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
mfa_code?: string
|
||||
}
|
||||
|
||||
export interface RefreshRequest {
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user_id: string
|
||||
org_id: string
|
||||
role?: string
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
export interface MFARequiredResponse {
|
||||
mfa_required: true
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string
|
||||
}
|
||||
|
||||
// --- Request body types for create/update ---
|
||||
|
||||
export interface CreateVaultRequest {
|
||||
name: string
|
||||
description?: string
|
||||
app_id?: string
|
||||
}
|
||||
|
||||
export interface UpdateVaultRequest {
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface CreateWalletRequest {
|
||||
name?: string
|
||||
key_type?: string // secp256k1 | ed25519
|
||||
}
|
||||
|
||||
export interface ReshareWalletRequest {
|
||||
new_threshold: number
|
||||
new_participants: string[]
|
||||
}
|
||||
|
||||
export interface CreateTransactionRequest {
|
||||
wallet_id: string
|
||||
tx_type: string
|
||||
chain: string
|
||||
to_address: string
|
||||
amount: string
|
||||
token?: string
|
||||
raw_tx?: string
|
||||
}
|
||||
|
||||
export interface RejectTransactionRequest {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface CreatePolicyRequest {
|
||||
vault_id?: string
|
||||
name: string
|
||||
priority: number
|
||||
action: string
|
||||
conditions: Record<string, unknown>
|
||||
required_approvers: number
|
||||
approver_roles: string[]
|
||||
}
|
||||
|
||||
export interface UpdatePolicyRequest {
|
||||
name?: string
|
||||
priority?: number
|
||||
action?: string
|
||||
conditions?: Record<string, unknown>
|
||||
required_approvers?: number
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface AddWhitelistRequest {
|
||||
vault_id?: string
|
||||
address: string
|
||||
chain: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface CreateWebhookRequest {
|
||||
url: string
|
||||
events: string[]
|
||||
secret: string
|
||||
}
|
||||
|
||||
export interface UpdateWebhookRequest {
|
||||
url?: string
|
||||
events?: string[]
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface CreateSubscriptionRequest {
|
||||
wallet_id: string
|
||||
name: string
|
||||
provider_name?: string
|
||||
recipient_address: string
|
||||
chain: string
|
||||
token?: string
|
||||
amount: string
|
||||
currency?: string
|
||||
interval: string
|
||||
require_balance?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateSubscriptionRequest {
|
||||
status?: string
|
||||
amount?: string
|
||||
}
|
||||
|
||||
export interface CreatePaymentRequestRequest {
|
||||
wallet_id?: string
|
||||
merchant_name?: string
|
||||
recipient_address: string
|
||||
chain: string
|
||||
token?: string
|
||||
amount: string
|
||||
memo?: string
|
||||
expires_in_hours?: number
|
||||
}
|
||||
|
||||
export interface PayPaymentRequestRequest {
|
||||
wallet_id: string
|
||||
}
|
||||
|
||||
export interface DeploySmartWalletRequest {
|
||||
chain: string
|
||||
wallet_type: string // safe | erc4337
|
||||
factory_address?: string
|
||||
entrypoint_address?: string
|
||||
salt?: string
|
||||
owners: string[]
|
||||
threshold: number
|
||||
}
|
||||
|
||||
export interface ProposeSafeTxRequest {
|
||||
to: string
|
||||
value: string
|
||||
data?: string
|
||||
}
|
||||
|
||||
export interface ExecuteSafeTxRequest {
|
||||
safe_tx_hash: string
|
||||
}
|
||||
|
||||
export interface UserOperationRequest {
|
||||
call_data: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
export interface InviteUserRequest {
|
||||
email: string
|
||||
role?: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
role?: string
|
||||
}
|
||||
|
||||
export interface CreateAPIKeyRequest {
|
||||
name: string
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
export interface TransactionFilters {
|
||||
status?: string
|
||||
chain?: string
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -0,0 +1,23 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
experimental: {
|
||||
typedRoutes: false,
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: false,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: false,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/v1/:path*',
|
||||
destination: 'http://localhost:8081/api/v1/:path*',
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
Generated
+6224
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@luxfi/mpc-dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"@tanstack/react-query": "^5.60.0",
|
||||
"zustand": "^5.0.0",
|
||||
"geist": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = config
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./lib/**/*.{ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['var(--font-geist-sans)'],
|
||||
mono: ['var(--font-geist-mono)'],
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -42,10 +42,13 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/go-chi/cors v1.2.2 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/flatbuffers v25.12.19+incompatible // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
@@ -58,6 +61,9 @@ require (
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/serf v0.10.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
|
||||
@@ -62,6 +62,10 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
@@ -82,6 +86,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@@ -147,6 +153,12 @@ github.com/hashicorp/memberlist v0.5.2 h1:rJoNPWZ0juJBgqn48gjy59K5H4rNgvUoM1kUD7
|
||||
github.com/hashicorp/memberlist v0.5.2/go.mod h1:Ri9p/tRShbjYnpNf4FFPXG7wxEGY4Nrcn6E7jrVa//4=
|
||||
github.com/hashicorp/serf v0.10.2 h1:m5IORhuNSjaxeljg5DeQVDlQyVkhRIjJDimbkCa8aAc=
|
||||
github.com/hashicorp/serf v0.10.2/go.mod h1:T1CmSGfSeGfnfNy/w0odXQUR1rfECGd2Qdsp84DjOiY=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
@@ -293,6 +305,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
@@ -411,5 +424,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mpc-api
|
||||
namespace: lux-mpc
|
||||
labels:
|
||||
app: mpc-api
|
||||
app.kubernetes.io/part-of: lux-mpc
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: mpc-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: mpc-api
|
||||
app.kubernetes.io/part-of: lux-mpc
|
||||
spec:
|
||||
containers:
|
||||
- name: mpc-api
|
||||
image: ghcr.io/luxfi/mpc-api:latest
|
||||
args:
|
||||
- start
|
||||
- --mode
|
||||
- consensus
|
||||
- --api-db
|
||||
- $(DATABASE_URL)
|
||||
- --api-listen
|
||||
- ":8081"
|
||||
- --jwt-secret
|
||||
- $(JWT_SECRET)
|
||||
ports:
|
||||
- containerPort: 8081
|
||||
name: api
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mpc-secrets
|
||||
key: DATABASE_URL
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mpc-secrets
|
||||
key: jwt-secret
|
||||
# NOTE: /api/v1/status requires auth and returns 401 for unauthenticated
|
||||
# requests. If the probe fails, add a public /healthz endpoint to the API
|
||||
# or switch to tcpSocket probe.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/status
|
||||
port: 8081
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/status
|
||||
port: 8081
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mpc-api-svc
|
||||
namespace: lux-mpc
|
||||
labels:
|
||||
app: mpc-api
|
||||
app.kubernetes.io/part-of: lux-mpc
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 8081
|
||||
targetPort: 8081
|
||||
protocol: TCP
|
||||
name: api
|
||||
selector:
|
||||
app: mpc-api
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: mpc-lux-network-tls
|
||||
namespace: lux-mpc
|
||||
labels:
|
||||
app.kubernetes.io/part-of: lux-mpc
|
||||
spec:
|
||||
secretName: mpc-lux-network-tls
|
||||
dnsNames:
|
||||
- mpc.lux.network
|
||||
- cloud.lux.network
|
||||
issuerRef:
|
||||
name: letsencrypt-prod
|
||||
kind: ClusterIssuer
|
||||
@@ -0,0 +1,52 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mpc-dashboard
|
||||
namespace: lux-mpc
|
||||
labels:
|
||||
app: mpc-dashboard
|
||||
app.kubernetes.io/part-of: lux-mpc
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: mpc-dashboard
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: mpc-dashboard
|
||||
app.kubernetes.io/part-of: lux-mpc
|
||||
spec:
|
||||
containers:
|
||||
- name: dashboard
|
||||
image: ghcr.io/luxfi/mpc-dashboard:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
name: http
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: NEXT_PUBLIC_API_URL
|
||||
value: "https://mpc.lux.network/api/v1"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mpc-dashboard-svc
|
||||
namespace: lux-mpc
|
||||
labels:
|
||||
app: mpc-dashboard
|
||||
app.kubernetes.io/part-of: lux-mpc
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: mpc-dashboard
|
||||
@@ -0,0 +1,27 @@
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: mpc-cloud
|
||||
namespace: lux-mpc
|
||||
labels:
|
||||
app.kubernetes.io/part-of: lux-mpc
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
# API routes - both domains
|
||||
- match: (Host(`mpc.lux.network`) || Host(`cloud.lux.network`)) && PathPrefix(`/api/v1`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: mpc-api-svc
|
||||
port: 8081
|
||||
passHostHeader: false
|
||||
# Dashboard - catch-all for both domains
|
||||
- match: Host(`mpc.lux.network`) || Host(`cloud.lux.network`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: mpc-dashboard-svc
|
||||
port: 3000
|
||||
passHostHeader: false
|
||||
tls:
|
||||
secretName: mpc-lux-network-tls
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: lux-mpc
|
||||
labels:
|
||||
app.kubernetes.io/part-of: lux-mpc
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mpc-postgres
|
||||
namespace: lux-mpc
|
||||
labels:
|
||||
app: mpc-postgres
|
||||
app.kubernetes.io/part-of: lux-mpc
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
protocol: TCP
|
||||
name: postgres
|
||||
selector:
|
||||
app: mpc-postgres
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: mpc-postgres
|
||||
namespace: lux-mpc
|
||||
labels:
|
||||
app: mpc-postgres
|
||||
app.kubernetes.io/part-of: lux-mpc
|
||||
spec:
|
||||
serviceName: mpc-postgres
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: mpc-postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: mpc-postgres
|
||||
app.kubernetes.io/part-of: lux-mpc
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
name: postgres
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: mpc
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mpc-secrets
|
||||
key: postgres-password
|
||||
- name: POSTGRES_DB
|
||||
value: mpc_api
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- mpc
|
||||
- -d
|
||||
- mpc_api
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- mpc
|
||||
- -d
|
||||
- mpc_api
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 5
|
||||
volumeMounts:
|
||||
- name: postgres-data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: postgres-data
|
||||
labels:
|
||||
app: mpc-postgres
|
||||
app.kubernetes.io/part-of: lux-mpc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: mpc-secrets
|
||||
namespace: lux-mpc
|
||||
labels:
|
||||
app.kubernetes.io/part-of: lux-mpc
|
||||
type: Opaque
|
||||
stringData:
|
||||
# TODO: Replace with a strong random password (e.g. openssl rand -base64 32)
|
||||
postgres-password: "CHANGE-ME-postgres-password"
|
||||
# TODO: Replace with a strong random JWT secret (e.g. openssl rand -base64 64)
|
||||
jwt-secret: "CHANGE-ME-jwt-secret"
|
||||
# Constructed from the above password - update if postgres-password changes
|
||||
DATABASE_URL: "postgres://mpc:[email protected]:5432/mpc_api?sslmode=disable"
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type JWTClaims struct {
|
||||
UserID string `json:"user_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func (s *Server) generateJWT(userID, orgID, role string) (string, error) {
|
||||
claims := JWTClaims{
|
||||
UserID: userID,
|
||||
OrgID: orgID,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(s.jwtSecret)
|
||||
}
|
||||
|
||||
func (s *Server) generateRefreshToken(userID, orgID, role string) (string, error) {
|
||||
claims := JWTClaims{
|
||||
UserID: userID,
|
||||
OrgID: orgID,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(s.jwtSecret)
|
||||
}
|
||||
|
||||
func (s *Server) validateJWT(tokenStr string) (*JWTClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return s.jwtSecret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func checkPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func generateAPIKeyToken() (string, string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
key := "lux_" + hex.EncodeToString(b)
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
return key, hex.EncodeToString(hash[:]), nil
|
||||
}
|
||||
|
||||
func (s *Server) validateAPIKey(ctx context.Context, key string) (string, error) {
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
keyHash := hex.EncodeToString(hash[:])
|
||||
prefix := key
|
||||
if len(prefix) > 8 {
|
||||
prefix = prefix[:8]
|
||||
}
|
||||
|
||||
var orgID string
|
||||
err := s.db.Pool.QueryRow(ctx,
|
||||
`UPDATE api_keys SET last_used_at = NOW()
|
||||
WHERE key_hash = $1 AND key_prefix = $2
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
RETURNING org_id`, keyHash, prefix).Scan(&orgID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid api key")
|
||||
}
|
||||
return orgID, nil
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func newTestServer(secret string) *Server {
|
||||
return &Server{
|
||||
jwtSecret: []byte(secret),
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAndValidateJWT(t *testing.T) {
|
||||
s := newTestServer("test-secret-key-for-jwt")
|
||||
|
||||
token, err := s.generateJWT("user-123", "org-456", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("generateJWT: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("generateJWT returned empty token")
|
||||
}
|
||||
|
||||
claims, err := s.validateJWT(token)
|
||||
if err != nil {
|
||||
t.Fatalf("validateJWT: %v", err)
|
||||
}
|
||||
|
||||
if claims.UserID != "user-123" {
|
||||
t.Errorf("UserID = %q, want %q", claims.UserID, "user-123")
|
||||
}
|
||||
if claims.OrgID != "org-456" {
|
||||
t.Errorf("OrgID = %q, want %q", claims.OrgID, "org-456")
|
||||
}
|
||||
if claims.Role != "admin" {
|
||||
t.Errorf("Role = %q, want %q", claims.Role, "admin")
|
||||
}
|
||||
|
||||
// Check expiry is ~24h from now
|
||||
if claims.ExpiresAt == nil {
|
||||
t.Fatal("ExpiresAt is nil")
|
||||
}
|
||||
expDelta := time.Until(claims.ExpiresAt.Time)
|
||||
if expDelta < 23*time.Hour || expDelta > 25*time.Hour {
|
||||
t.Errorf("ExpiresAt delta = %v, want ~24h", expDelta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPassword(t *testing.T) {
|
||||
password := "correct-horse-battery-staple"
|
||||
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("hashPassword: %v", err)
|
||||
}
|
||||
if hash == "" {
|
||||
t.Fatal("hashPassword returned empty hash")
|
||||
}
|
||||
if hash == password {
|
||||
t.Fatal("hash must not equal plaintext password")
|
||||
}
|
||||
|
||||
// Correct password verifies
|
||||
if !checkPassword(hash, password) {
|
||||
t.Error("checkPassword(hash, correct) = false, want true")
|
||||
}
|
||||
|
||||
// Wrong password does not verify
|
||||
if checkPassword(hash, "wrong-password") {
|
||||
t.Error("checkPassword(hash, wrong) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAPIKey(t *testing.T) {
|
||||
key, hash, err := generateAPIKeyToken()
|
||||
if err != nil {
|
||||
t.Fatalf("generateAPIKeyToken: %v", err)
|
||||
}
|
||||
|
||||
// Key has "lux_" prefix
|
||||
if !strings.HasPrefix(key, "lux_") {
|
||||
t.Errorf("key prefix = %q, want lux_", key[:4])
|
||||
}
|
||||
|
||||
// Key is lux_ + 64 hex chars = 68 chars total
|
||||
if len(key) != 68 {
|
||||
t.Errorf("key length = %d, want 68", len(key))
|
||||
}
|
||||
|
||||
// Hash is a 64-char hex string (SHA-256)
|
||||
if len(hash) != 64 {
|
||||
t.Errorf("hash length = %d, want 64", len(hash))
|
||||
}
|
||||
|
||||
// Two calls produce different keys
|
||||
key2, hash2, err := generateAPIKeyToken()
|
||||
if err != nil {
|
||||
t.Fatalf("generateAPIKeyToken (2nd call): %v", err)
|
||||
}
|
||||
if key == key2 {
|
||||
t.Error("two calls produced identical keys")
|
||||
}
|
||||
if hash == hash2 {
|
||||
t.Error("two calls produced identical hashes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidJWT_WrongSecret(t *testing.T) {
|
||||
s1 := newTestServer("secret-one")
|
||||
s2 := newTestServer("secret-two")
|
||||
|
||||
token, err := s1.generateJWT("user-1", "org-1", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("generateJWT: %v", err)
|
||||
}
|
||||
|
||||
// Validate with different secret must fail
|
||||
_, err = s2.validateJWT(token)
|
||||
if err == nil {
|
||||
t.Fatal("validateJWT with wrong secret should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidJWT_Expired(t *testing.T) {
|
||||
s := newTestServer("test-secret")
|
||||
|
||||
// Manually create an expired token
|
||||
claims := JWTClaims{
|
||||
UserID: "user-exp",
|
||||
OrgID: "org-exp",
|
||||
Role: "viewer",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenStr, err := token.SignedString(s.jwtSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("sign expired token: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.validateJWT(tokenStr)
|
||||
if err == nil {
|
||||
t.Fatal("validateJWT should reject expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidJWT_Malformed(t *testing.T) {
|
||||
s := newTestServer("test-secret")
|
||||
|
||||
_, err := s.validateJWT("not-a-jwt-token")
|
||||
if err == nil {
|
||||
t.Fatal("validateJWT should reject malformed token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidJWT_EmptyString(t *testing.T) {
|
||||
s := newTestServer("test-secret")
|
||||
|
||||
_, err := s.validateJWT("")
|
||||
if err == nil {
|
||||
t.Fatal("validateJWT should reject empty string")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/db"
|
||||
)
|
||||
|
||||
func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
rows, err := s.db.Pool.Query(r.Context(),
|
||||
`SELECT id, org_id, user_id, action, resource_type, resource_id,
|
||||
details, ip_address, created_at
|
||||
FROM audit_log WHERE org_id = $1 ORDER BY created_at DESC LIMIT 200`, orgID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []db.AuditEntry
|
||||
for rows.Next() {
|
||||
var e db.AuditEntry
|
||||
if err := rows.Scan(&e.ID, &e.OrgID, &e.UserID, &e.Action,
|
||||
&e.ResourceType, &e.ResourceID, &e.Details, &e.IPAddress,
|
||||
&e.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
if entries == nil {
|
||||
entries = []db.AuditEntry{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
status := s.mpc.GetClusterStatus()
|
||||
if status == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "unknown"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"name": "Lux MPC",
|
||||
"version": "0.3.3",
|
||||
"supported_chains": []string{"ethereum", "bitcoin", "solana", "lux"},
|
||||
"key_types": []string{"secp256k1", "ed25519"},
|
||||
"protocols": []string{"CGGMP21", "FROST", "LSS"},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
OrgName string `json:"org_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.OrgName == "" || req.Email == "" || req.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "org_name, email, and password are required")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
if !isValidEmail(req.Email) {
|
||||
writeError(w, http.StatusBadRequest, "invalid email address")
|
||||
return
|
||||
}
|
||||
|
||||
slug := slugify(req.OrgName)
|
||||
hash, err := hashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to hash password")
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := s.db.Pool.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
var orgID string
|
||||
err = tx.QueryRow(r.Context(),
|
||||
`INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING id`,
|
||||
req.OrgName, slug).Scan(&orgID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusConflict, "organization already exists")
|
||||
return
|
||||
}
|
||||
|
||||
var userID string
|
||||
err = tx.QueryRow(r.Context(),
|
||||
`INSERT INTO users (org_id, email, password_hash, role) VALUES ($1, $2, $3, 'owner') RETURNING id`,
|
||||
orgID, req.Email, hash).Scan(&userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusConflict, "email already registered")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := s.generateJWT(userID, orgID, "owner")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
||||
return
|
||||
}
|
||||
refreshToken, err := s.generateRefreshToken(userID, orgID, "owner")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"org_id": orgID,
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
MFACode string `json:"mfa_code,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var userID, orgID, role, passwordHash string
|
||||
var mfaSecret *string
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT u.id, u.org_id, u.role, u.password_hash, u.mfa_secret
|
||||
FROM users u WHERE u.email = $1`, req.Email).
|
||||
Scan(&userID, &orgID, &role, &passwordHash, &mfaSecret)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
if !checkPassword(passwordHash, req.Password) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
if mfaSecret != nil && *mfaSecret != "" {
|
||||
if req.MFACode == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"mfa_required": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
// TODO: validate TOTP code against mfaSecret
|
||||
}
|
||||
|
||||
accessToken, err := s.generateJWT(userID, orgID, role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
||||
return
|
||||
}
|
||||
refreshToken, err := s.generateRefreshToken(userID, orgID, role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"org_id": orgID,
|
||||
"role": role,
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := s.validateJWT(req.RefreshToken)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := s.generateJWT(claims.UserID, claims.OrgID, claims.Role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
||||
return
|
||||
}
|
||||
refreshToken, err := s.generateRefreshToken(claims.UserID, claims.OrgID, claims.Role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMFASetup(w http.ResponseWriter, r *http.Request) {
|
||||
userID := getUserID(r.Context())
|
||||
|
||||
secret := make([]byte, 20)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate secret")
|
||||
return
|
||||
}
|
||||
encoded := base32.StdEncoding.EncodeToString(secret)
|
||||
|
||||
_, err := s.db.Pool.Exec(r.Context(),
|
||||
`UPDATE users SET mfa_secret = $1 WHERE id = $2`, encoded, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"secret": encoded,
|
||||
"otpauth": "otpauth://totp/LuxMPC?secret=" + encoded + "&issuer=LuxMPC",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMFAVerify(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
// TODO: Implement TOTP verification
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "verified"})
|
||||
}
|
||||
|
||||
func slugify(name string) string {
|
||||
s := strings.ToLower(name)
|
||||
s = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
return s
|
||||
}
|
||||
|
||||
func isValidEmail(email string) bool {
|
||||
return regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`).MatchString(email)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/db"
|
||||
)
|
||||
|
||||
func (s *Server) handleCreatePaymentRequest(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
|
||||
var req struct {
|
||||
WalletID *string `json:"wallet_id,omitempty"`
|
||||
MerchantName *string `json:"merchant_name,omitempty"`
|
||||
RecipientAddress string `json:"recipient_address"`
|
||||
Chain string `json:"chain"`
|
||||
Token *string `json:"token,omitempty"`
|
||||
Amount string `json:"amount"`
|
||||
Memo *string `json:"memo,omitempty"`
|
||||
ExpiresInHours int `json:"expires_in_hours,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.RecipientAddress == "" || req.Chain == "" || req.Amount == "" {
|
||||
writeError(w, http.StatusBadRequest, "recipient_address, chain, and amount are required")
|
||||
return
|
||||
}
|
||||
|
||||
token := generateRequestToken()
|
||||
var expiresAt *time.Time
|
||||
if req.ExpiresInHours > 0 {
|
||||
t := time.Now().Add(time.Duration(req.ExpiresInHours) * time.Hour)
|
||||
expiresAt = &t
|
||||
}
|
||||
|
||||
var pr db.PaymentRequest
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`INSERT INTO payment_requests (org_id, wallet_id, request_token, merchant_name,
|
||||
recipient_address, chain, token, amount, memo, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, org_id, wallet_id, request_token, merchant_name,
|
||||
recipient_address, chain, token, amount, memo, status, expires_at, created_at`,
|
||||
orgID, req.WalletID, token, req.MerchantName,
|
||||
req.RecipientAddress, req.Chain, req.Token, req.Amount, req.Memo, expiresAt).
|
||||
Scan(&pr.ID, &pr.OrgID, &pr.WalletID, &pr.RequestToken, &pr.MerchantName,
|
||||
&pr.RecipientAddress, &pr.Chain, &pr.Token, &pr.Amount, &pr.Memo,
|
||||
&pr.Status, &pr.ExpiresAt, &pr.CreatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create payment request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"payment_request": pr,
|
||||
"payment_url": "https://mpc.lux.network/pay/" + token,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleListPaymentRequests(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
rows, err := s.db.Pool.Query(r.Context(),
|
||||
`SELECT id, org_id, wallet_id, request_token, merchant_name,
|
||||
recipient_address, chain, token, amount, memo,
|
||||
status, expires_at, paid_tx_id, created_at
|
||||
FROM payment_requests WHERE org_id = $1 ORDER BY created_at DESC`, orgID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var requests []db.PaymentRequest
|
||||
for rows.Next() {
|
||||
var pr db.PaymentRequest
|
||||
if err := rows.Scan(&pr.ID, &pr.OrgID, &pr.WalletID, &pr.RequestToken,
|
||||
&pr.MerchantName, &pr.RecipientAddress, &pr.Chain, &pr.Token,
|
||||
&pr.Amount, &pr.Memo, &pr.Status, &pr.ExpiresAt, &pr.PaidTxID,
|
||||
&pr.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
requests = append(requests, pr)
|
||||
}
|
||||
if requests == nil {
|
||||
requests = []db.PaymentRequest{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, requests)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetPaymentRequest(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
prID := urlParam(r, "id")
|
||||
|
||||
var pr db.PaymentRequest
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT id, org_id, wallet_id, request_token, merchant_name,
|
||||
recipient_address, chain, token, amount, memo,
|
||||
status, expires_at, paid_tx_id, created_at
|
||||
FROM payment_requests WHERE id = $1 AND org_id = $2`, prID, orgID).
|
||||
Scan(&pr.ID, &pr.OrgID, &pr.WalletID, &pr.RequestToken,
|
||||
&pr.MerchantName, &pr.RecipientAddress, &pr.Chain, &pr.Token,
|
||||
&pr.Amount, &pr.Memo, &pr.Status, &pr.ExpiresAt, &pr.PaidTxID,
|
||||
&pr.CreatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "payment request not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, pr)
|
||||
}
|
||||
|
||||
func (s *Server) handlePayPaymentRequest(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
prID := urlParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
WalletID string `json:"wallet_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var pr db.PaymentRequest
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT id, recipient_address, chain, token, amount, status, expires_at
|
||||
FROM payment_requests WHERE id = $1 AND org_id = $2`, prID, orgID).
|
||||
Scan(&pr.ID, &pr.RecipientAddress, &pr.Chain, &pr.Token, &pr.Amount,
|
||||
&pr.Status, &pr.ExpiresAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "payment request not found")
|
||||
return
|
||||
}
|
||||
if pr.Status != "pending" {
|
||||
writeError(w, http.StatusBadRequest, "payment request is not pending")
|
||||
return
|
||||
}
|
||||
if pr.ExpiresAt != nil && pr.ExpiresAt.Before(time.Now()) {
|
||||
writeError(w, http.StatusBadRequest, "payment request has expired")
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := ""
|
||||
if pr.Token != nil {
|
||||
tokenStr = *pr.Token
|
||||
}
|
||||
|
||||
var txID string
|
||||
err = s.db.Pool.QueryRow(r.Context(),
|
||||
`INSERT INTO transactions (org_id, wallet_id, tx_type, chain, to_address, amount, token, status, initiated_by)
|
||||
VALUES ($1, $2, 'payment', $3, $4, $5, $6, 'approved', $7)
|
||||
RETURNING id`,
|
||||
orgID, req.WalletID, pr.Chain, pr.RecipientAddress, pr.Amount,
|
||||
nilIfEmpty(tokenStr), getUserID(r.Context())).Scan(&txID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create transaction")
|
||||
return
|
||||
}
|
||||
|
||||
s.db.Pool.Exec(r.Context(),
|
||||
`UPDATE payment_requests SET status = 'paid', paid_tx_id = $1 WHERE id = $2`,
|
||||
txID, prID)
|
||||
|
||||
go s.signAndBroadcast(txID, orgID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "payment_initiated",
|
||||
"tx_id": txID,
|
||||
})
|
||||
}
|
||||
|
||||
// handlePublicPay serves the public payment page (no auth required)
|
||||
func (s *Server) handlePublicPay(w http.ResponseWriter, r *http.Request) {
|
||||
token := urlParam(r, "token")
|
||||
|
||||
var pr db.PaymentRequest
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT id, merchant_name, recipient_address, chain, token, amount, memo,
|
||||
status, expires_at, created_at
|
||||
FROM payment_requests WHERE request_token = $1`, token).
|
||||
Scan(&pr.ID, &pr.MerchantName, &pr.RecipientAddress, &pr.Chain,
|
||||
&pr.Token, &pr.Amount, &pr.Memo, &pr.Status, &pr.ExpiresAt, &pr.CreatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "payment request not found")
|
||||
return
|
||||
}
|
||||
|
||||
if pr.Status != "pending" {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": pr.Status,
|
||||
"message": "this payment request is no longer active",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"merchant_name": pr.MerchantName,
|
||||
"recipient_address": pr.RecipientAddress,
|
||||
"chain": pr.Chain,
|
||||
"token": pr.Token,
|
||||
"amount": pr.Amount,
|
||||
"memo": pr.Memo,
|
||||
"expires_at": pr.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
func generateRequestToken() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/db"
|
||||
)
|
||||
|
||||
func (s *Server) handleListPolicies(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
rows, err := s.db.Pool.Query(r.Context(),
|
||||
`SELECT id, org_id, vault_id, name, priority, action, conditions,
|
||||
required_approvers, approver_roles, enabled, created_at
|
||||
FROM policies WHERE org_id = $1 ORDER BY priority DESC`, orgID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var policies []db.Policy
|
||||
for rows.Next() {
|
||||
var p db.Policy
|
||||
if err := rows.Scan(&p.ID, &p.OrgID, &p.VaultID, &p.Name, &p.Priority,
|
||||
&p.Action, &p.Conditions, &p.RequiredApprovers, &p.ApproverRoles,
|
||||
&p.Enabled, &p.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
policies = append(policies, p)
|
||||
}
|
||||
if policies == nil {
|
||||
policies = []db.Policy{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, policies)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreatePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
var req struct {
|
||||
VaultID *string `json:"vault_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Priority int `json:"priority"`
|
||||
Action string `json:"action"`
|
||||
Conditions json.RawMessage `json:"conditions"`
|
||||
RequiredApprovers int `json:"required_approvers"`
|
||||
ApproverRoles []string `json:"approver_roles"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Action == "" {
|
||||
writeError(w, http.StatusBadRequest, "name and action are required")
|
||||
return
|
||||
}
|
||||
|
||||
var policy db.Policy
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`INSERT INTO policies (org_id, vault_id, name, priority, action, conditions, required_approvers, approver_roles)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, org_id, vault_id, name, priority, action, conditions,
|
||||
required_approvers, approver_roles, enabled, created_at`,
|
||||
orgID, req.VaultID, req.Name, req.Priority, req.Action,
|
||||
[]byte(req.Conditions), req.RequiredApprovers, req.ApproverRoles).
|
||||
Scan(&policy.ID, &policy.OrgID, &policy.VaultID, &policy.Name, &policy.Priority,
|
||||
&policy.Action, &policy.Conditions, &policy.RequiredApprovers, &policy.ApproverRoles,
|
||||
&policy.Enabled, &policy.CreatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create policy: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, policy)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdatePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
policyID := urlParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Action *string `json:"action,omitempty"`
|
||||
Conditions *json.RawMessage `json:"conditions,omitempty"`
|
||||
RequiredApprovers *int `json:"required_approvers,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var policy db.Policy
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`UPDATE policies SET
|
||||
name = COALESCE($1, name),
|
||||
priority = COALESCE($2, priority),
|
||||
action = COALESCE($3, action),
|
||||
conditions = COALESCE($4, conditions),
|
||||
required_approvers = COALESCE($5, required_approvers),
|
||||
enabled = COALESCE($6, enabled)
|
||||
WHERE id = $7 AND org_id = $8
|
||||
RETURNING id, org_id, vault_id, name, priority, action, conditions,
|
||||
required_approvers, approver_roles, enabled, created_at`,
|
||||
req.Name, req.Priority, req.Action, req.Conditions,
|
||||
req.RequiredApprovers, req.Enabled, policyID, orgID).
|
||||
Scan(&policy.ID, &policy.OrgID, &policy.VaultID, &policy.Name, &policy.Priority,
|
||||
&policy.Action, &policy.Conditions, &policy.RequiredApprovers, &policy.ApproverRoles,
|
||||
&policy.Enabled, &policy.CreatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "policy not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, policy)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeletePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
policyID := urlParam(r, "id")
|
||||
|
||||
tag, err := s.db.Pool.Exec(r.Context(),
|
||||
`DELETE FROM policies WHERE id = $1 AND org_id = $2`, policyID, orgID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
writeError(w, http.StatusNotFound, "policy not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/db"
|
||||
)
|
||||
|
||||
func (s *Server) handleDeploySmartWallet(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
walletID := urlParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
Chain string `json:"chain"`
|
||||
WalletType string `json:"wallet_type"` // safe, erc4337
|
||||
FactoryAddress *string `json:"factory_address,omitempty"`
|
||||
EntrypointAddress *string `json:"entrypoint_address,omitempty"`
|
||||
Salt *string `json:"salt,omitempty"`
|
||||
Owners []string `json:"owners"`
|
||||
Threshold int `json:"threshold"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Chain == "" || req.WalletType == "" || len(req.Owners) == 0 || req.Threshold == 0 {
|
||||
writeError(w, http.StatusBadRequest, "chain, wallet_type, owners, and threshold are required")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify wallet belongs to org
|
||||
var ethAddress *string
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT eth_address FROM wallets WHERE id = $1 AND org_id = $2`,
|
||||
walletID, orgID).Scan(ðAddress)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "wallet not found")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Deploy smart wallet via Safe SDK or ERC-4337 factory
|
||||
// For now, record the intent
|
||||
contractAddress := "0x0000000000000000000000000000000000000000" // placeholder
|
||||
|
||||
var sw db.SmartWallet
|
||||
err = s.db.Pool.QueryRow(r.Context(),
|
||||
`INSERT INTO smart_wallets (wallet_id, org_id, chain, contract_address, wallet_type,
|
||||
factory_address, entrypoint_address, salt, owners, threshold)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, wallet_id, org_id, chain, contract_address, wallet_type,
|
||||
factory_address, entrypoint_address, salt, owners, threshold, status, deployed_at, created_at`,
|
||||
walletID, orgID, req.Chain, contractAddress, req.WalletType,
|
||||
req.FactoryAddress, req.EntrypointAddress, req.Salt, req.Owners, req.Threshold).
|
||||
Scan(&sw.ID, &sw.WalletID, &sw.OrgID, &sw.Chain, &sw.ContractAddress,
|
||||
&sw.WalletType, &sw.FactoryAddress, &sw.EntrypointAddress, &sw.Salt,
|
||||
&sw.Owners, &sw.Threshold, &sw.Status, &sw.DeployedAt, &sw.CreatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create smart wallet: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, sw)
|
||||
}
|
||||
|
||||
func (s *Server) handleListSmartWallets(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
walletID := urlParam(r, "id")
|
||||
|
||||
rows, err := s.db.Pool.Query(r.Context(),
|
||||
`SELECT id, wallet_id, org_id, chain, contract_address, wallet_type,
|
||||
factory_address, entrypoint_address, salt, owners, threshold,
|
||||
status, deployed_at, created_at
|
||||
FROM smart_wallets WHERE wallet_id = $1 AND org_id = $2
|
||||
ORDER BY created_at DESC`, walletID, orgID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var wallets []db.SmartWallet
|
||||
for rows.Next() {
|
||||
var sw db.SmartWallet
|
||||
if err := rows.Scan(&sw.ID, &sw.WalletID, &sw.OrgID, &sw.Chain,
|
||||
&sw.ContractAddress, &sw.WalletType, &sw.FactoryAddress,
|
||||
&sw.EntrypointAddress, &sw.Salt, &sw.Owners, &sw.Threshold,
|
||||
&sw.Status, &sw.DeployedAt, &sw.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
wallets = append(wallets, sw)
|
||||
}
|
||||
if wallets == nil {
|
||||
wallets = []db.SmartWallet{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, wallets)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSmartWallet(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
swID := urlParam(r, "id")
|
||||
|
||||
var sw db.SmartWallet
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT id, wallet_id, org_id, chain, contract_address, wallet_type,
|
||||
factory_address, entrypoint_address, salt, owners, threshold,
|
||||
status, deployed_at, created_at
|
||||
FROM smart_wallets WHERE id = $1 AND org_id = $2`, swID, orgID).
|
||||
Scan(&sw.ID, &sw.WalletID, &sw.OrgID, &sw.Chain, &sw.ContractAddress,
|
||||
&sw.WalletType, &sw.FactoryAddress, &sw.EntrypointAddress, &sw.Salt,
|
||||
&sw.Owners, &sw.Threshold, &sw.Status, &sw.DeployedAt, &sw.CreatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "smart wallet not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sw)
|
||||
}
|
||||
|
||||
func (s *Server) handleProposeSafeTx(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
swID := urlParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
To string `json:"to"`
|
||||
Value string `json:"value"`
|
||||
Data string `json:"data,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify smart wallet
|
||||
var walletID, chain string
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT wallet_id, chain FROM smart_wallets WHERE id = $1 AND org_id = $2`,
|
||||
swID, orgID).Scan(&walletID, &chain)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "smart wallet not found")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Create Safe transaction proposal, sign with MPC
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "proposed",
|
||||
"smart_wallet_id": swID,
|
||||
"to": req.To,
|
||||
"value": req.Value,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleExecuteSafeTx(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
swID := urlParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
SafeTxHash string `json:"safe_tx_hash"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var contractAddress string
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT contract_address FROM smart_wallets WHERE id = $1 AND org_id = $2`,
|
||||
swID, orgID).Scan(&contractAddress)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "smart wallet not found")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Execute Safe transaction
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "executing",
|
||||
"safe_tx_hash": req.SafeTxHash,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUserOperation(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
swID := urlParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
CallData string `json:"call_data"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var walletType string
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT wallet_type FROM smart_wallets WHERE id = $1 AND org_id = $2`,
|
||||
swID, orgID).Scan(&walletType)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "smart wallet not found")
|
||||
return
|
||||
}
|
||||
if walletType != "erc4337" {
|
||||
writeError(w, http.StatusBadRequest, "user operations only supported for ERC-4337 wallets")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Build and submit UserOperation
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "submitted",
|
||||
"smart_wallet_id": swID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/db"
|
||||
)
|
||||
|
||||
func (s *Server) handleListSubscriptions(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
rows, err := s.db.Pool.Query(r.Context(),
|
||||
`SELECT id, org_id, wallet_id, name, provider_name, recipient_address,
|
||||
chain, token, amount, currency, interval,
|
||||
next_payment_at, last_payment_at, last_tx_id,
|
||||
status, max_retries, retry_count, require_balance,
|
||||
created_by, created_at
|
||||
FROM subscriptions WHERE org_id = $1 ORDER BY created_at DESC`, orgID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var subs []db.Subscription
|
||||
for rows.Next() {
|
||||
var sub db.Subscription
|
||||
if err := rows.Scan(&sub.ID, &sub.OrgID, &sub.WalletID, &sub.Name,
|
||||
&sub.ProviderName, &sub.RecipientAddress, &sub.Chain, &sub.Token,
|
||||
&sub.Amount, &sub.Currency, &sub.Interval,
|
||||
&sub.NextPaymentAt, &sub.LastPaymentAt, &sub.LastTxID,
|
||||
&sub.Status, &sub.MaxRetries, &sub.RetryCount, &sub.RequireBalance,
|
||||
&sub.CreatedBy, &sub.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
subs = append(subs, sub)
|
||||
}
|
||||
if subs == nil {
|
||||
subs = []db.Subscription{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, subs)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
userID := getUserID(r.Context())
|
||||
|
||||
var req struct {
|
||||
WalletID string `json:"wallet_id"`
|
||||
Name string `json:"name"`
|
||||
ProviderName *string `json:"provider_name,omitempty"`
|
||||
RecipientAddress string `json:"recipient_address"`
|
||||
Chain string `json:"chain"`
|
||||
Token *string `json:"token,omitempty"`
|
||||
Amount string `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Interval string `json:"interval"`
|
||||
RequireBalance bool `json:"require_balance"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.RecipientAddress == "" || req.Chain == "" || req.Amount == "" || req.Interval == "" {
|
||||
writeError(w, http.StatusBadRequest, "name, recipient_address, chain, amount, and interval are required")
|
||||
return
|
||||
}
|
||||
if req.Currency == "" {
|
||||
req.Currency = "USD"
|
||||
}
|
||||
|
||||
nextPayment := computeNextPayment(req.Interval)
|
||||
|
||||
var sub db.Subscription
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`INSERT INTO subscriptions (org_id, wallet_id, name, provider_name, recipient_address,
|
||||
chain, token, amount, currency, interval, next_payment_at, require_balance, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id, org_id, wallet_id, name, provider_name, recipient_address,
|
||||
chain, token, amount, currency, interval,
|
||||
next_payment_at, last_payment_at, last_tx_id,
|
||||
status, max_retries, retry_count, require_balance, created_by, created_at`,
|
||||
orgID, req.WalletID, req.Name, req.ProviderName, req.RecipientAddress,
|
||||
req.Chain, req.Token, req.Amount, req.Currency, req.Interval,
|
||||
nextPayment, req.RequireBalance, userID).
|
||||
Scan(&sub.ID, &sub.OrgID, &sub.WalletID, &sub.Name,
|
||||
&sub.ProviderName, &sub.RecipientAddress, &sub.Chain, &sub.Token,
|
||||
&sub.Amount, &sub.Currency, &sub.Interval,
|
||||
&sub.NextPaymentAt, &sub.LastPaymentAt, &sub.LastTxID,
|
||||
&sub.Status, &sub.MaxRetries, &sub.RetryCount, &sub.RequireBalance,
|
||||
&sub.CreatedBy, &sub.CreatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create subscription: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, sub)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
subID := urlParam(r, "id")
|
||||
|
||||
var sub db.Subscription
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT id, org_id, wallet_id, name, provider_name, recipient_address,
|
||||
chain, token, amount, currency, interval,
|
||||
next_payment_at, last_payment_at, last_tx_id,
|
||||
status, max_retries, retry_count, require_balance,
|
||||
created_by, created_at
|
||||
FROM subscriptions WHERE id = $1 AND org_id = $2`, subID, orgID).
|
||||
Scan(&sub.ID, &sub.OrgID, &sub.WalletID, &sub.Name,
|
||||
&sub.ProviderName, &sub.RecipientAddress, &sub.Chain, &sub.Token,
|
||||
&sub.Amount, &sub.Currency, &sub.Interval,
|
||||
&sub.NextPaymentAt, &sub.LastPaymentAt, &sub.LastTxID,
|
||||
&sub.Status, &sub.MaxRetries, &sub.RetryCount, &sub.RequireBalance,
|
||||
&sub.CreatedBy, &sub.CreatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "subscription not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sub)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
subID := urlParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
Status *string `json:"status,omitempty"`
|
||||
Amount *string `json:"amount,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var sub db.Subscription
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`UPDATE subscriptions SET
|
||||
status = COALESCE($1, status),
|
||||
amount = COALESCE($2, amount)
|
||||
WHERE id = $3 AND org_id = $4
|
||||
RETURNING id, org_id, wallet_id, name, provider_name, recipient_address,
|
||||
chain, token, amount, currency, interval,
|
||||
next_payment_at, last_payment_at, last_tx_id,
|
||||
status, max_retries, retry_count, require_balance, created_by, created_at`,
|
||||
req.Status, req.Amount, subID, orgID).
|
||||
Scan(&sub.ID, &sub.OrgID, &sub.WalletID, &sub.Name,
|
||||
&sub.ProviderName, &sub.RecipientAddress, &sub.Chain, &sub.Token,
|
||||
&sub.Amount, &sub.Currency, &sub.Interval,
|
||||
&sub.NextPaymentAt, &sub.LastPaymentAt, &sub.LastTxID,
|
||||
&sub.Status, &sub.MaxRetries, &sub.RetryCount, &sub.RequireBalance,
|
||||
&sub.CreatedBy, &sub.CreatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "subscription not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sub)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
userID := getUserID(r.Context())
|
||||
subID := urlParam(r, "id")
|
||||
|
||||
now := time.Now()
|
||||
tag, err := s.db.Pool.Exec(r.Context(),
|
||||
`UPDATE subscriptions SET status = 'cancelled', cancelled_by = $1, cancelled_at = $2
|
||||
WHERE id = $3 AND org_id = $4 AND status != 'cancelled'`,
|
||||
userID, now, subID, orgID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
writeError(w, http.StatusNotFound, "subscription not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handlePayNow(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
subID := urlParam(r, "id")
|
||||
|
||||
var walletID, recipientAddress, chain, amount string
|
||||
var token *string
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT wallet_id, recipient_address, chain, amount, token
|
||||
FROM subscriptions WHERE id = $1 AND org_id = $2 AND status = 'active'`,
|
||||
subID, orgID).Scan(&walletID, &recipientAddress, &chain, &amount, &token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "subscription not found or not active")
|
||||
return
|
||||
}
|
||||
|
||||
// Create transaction for immediate payment
|
||||
var txID string
|
||||
tokenStr := ""
|
||||
if token != nil {
|
||||
tokenStr = *token
|
||||
}
|
||||
err = s.db.Pool.QueryRow(r.Context(),
|
||||
`INSERT INTO transactions (org_id, wallet_id, tx_type, chain, to_address, amount, token, status, initiated_by)
|
||||
VALUES ($1, $2, 'subscription_payment', $3, $4, $5, $6, 'approved', $7)
|
||||
RETURNING id`,
|
||||
orgID, walletID, chain, recipientAddress, amount, nilIfEmpty(tokenStr),
|
||||
getUserID(r.Context())).Scan(&txID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create payment transaction")
|
||||
return
|
||||
}
|
||||
|
||||
go s.signAndBroadcast(txID, orgID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "payment_initiated",
|
||||
"tx_id": txID,
|
||||
})
|
||||
}
|
||||
|
||||
func computeNextPayment(interval string) time.Time {
|
||||
now := time.Now()
|
||||
switch interval {
|
||||
case "daily":
|
||||
return now.Add(24 * time.Hour)
|
||||
case "weekly":
|
||||
return now.Add(7 * 24 * time.Hour)
|
||||
case "monthly":
|
||||
return now.AddDate(0, 1, 0)
|
||||
case "yearly":
|
||||
return now.AddDate(1, 0, 0)
|
||||
default:
|
||||
return now.Add(30 * 24 * time.Hour)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/db"
|
||||
)
|
||||
|
||||
func (s *Server) handleCreateTransaction(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
userID := getUserID(r.Context())
|
||||
|
||||
var req struct {
|
||||
WalletID string `json:"wallet_id"`
|
||||
TxType string `json:"tx_type"`
|
||||
Chain string `json:"chain"`
|
||||
ToAddress string `json:"to_address"`
|
||||
Amount string `json:"amount"`
|
||||
Token string `json:"token,omitempty"`
|
||||
RawTx string `json:"raw_tx,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.WalletID == "" || req.Chain == "" || req.TxType == "" {
|
||||
writeError(w, http.StatusBadRequest, "wallet_id, tx_type, and chain are required")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify wallet
|
||||
var walletOrgID string
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT org_id FROM wallets WHERE id = $1`, req.WalletID).Scan(&walletOrgID)
|
||||
if err != nil || walletOrgID != orgID {
|
||||
writeError(w, http.StatusNotFound, "wallet not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Run policy engine
|
||||
policies, err := s.loadPolicies(r.Context(), orgID, nil)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load policies")
|
||||
return
|
||||
}
|
||||
|
||||
decision := evaluateTransaction(req.Amount, req.Chain, req.ToAddress, policies)
|
||||
|
||||
status := "pending_approval"
|
||||
if decision.Action == "approve" {
|
||||
status = "approved"
|
||||
} else if decision.Action == "deny" {
|
||||
writeError(w, http.StatusForbidden, "transaction denied by policy: "+decision.Reason)
|
||||
return
|
||||
}
|
||||
|
||||
var rawTx []byte
|
||||
if req.RawTx != "" {
|
||||
rawTx, _ = hex.DecodeString(req.RawTx)
|
||||
}
|
||||
|
||||
var tx db.Transaction
|
||||
err = s.db.Pool.QueryRow(r.Context(),
|
||||
`INSERT INTO transactions (org_id, wallet_id, tx_type, chain, to_address, amount, token, raw_tx, status, initiated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, org_id, wallet_id, tx_type, chain, to_address, amount, token,
|
||||
tx_hash, status, initiated_by, created_at`,
|
||||
orgID, req.WalletID, req.TxType, req.Chain, req.ToAddress, req.Amount,
|
||||
nilIfEmpty(req.Token), rawTx, status, userID).
|
||||
Scan(&tx.ID, &tx.OrgID, &tx.WalletID, &tx.TxType, &tx.Chain,
|
||||
&tx.ToAddress, &tx.Amount, &tx.Token, &tx.TxHash, &tx.Status,
|
||||
&tx.InitiatedBy, &tx.CreatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create transaction: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if status == "approved" {
|
||||
go s.signAndBroadcast(tx.ID, orgID)
|
||||
}
|
||||
|
||||
s.fireWebhook(r.Context(), orgID, "tx.pending", tx)
|
||||
writeJSON(w, http.StatusCreated, tx)
|
||||
}
|
||||
|
||||
func (s *Server) handleListTransactions(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
statusFilter := r.URL.Query().Get("status")
|
||||
chain := r.URL.Query().Get("chain")
|
||||
|
||||
query := `SELECT id, org_id, wallet_id, tx_type, chain, to_address, amount, token,
|
||||
tx_hash, status, initiated_by, created_at, signed_at, broadcast_at
|
||||
FROM transactions WHERE org_id = $1`
|
||||
args := []interface{}{orgID}
|
||||
idx := 2
|
||||
|
||||
if statusFilter != "" {
|
||||
query += " AND status = $" + strconv.Itoa(idx)
|
||||
args = append(args, statusFilter)
|
||||
idx++
|
||||
}
|
||||
if chain != "" {
|
||||
query += " AND chain = $" + strconv.Itoa(idx)
|
||||
args = append(args, chain)
|
||||
idx++
|
||||
}
|
||||
query += " ORDER BY created_at DESC LIMIT 100"
|
||||
|
||||
rows, err := s.db.Pool.Query(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var txs []db.Transaction
|
||||
for rows.Next() {
|
||||
var tx db.Transaction
|
||||
if err := rows.Scan(&tx.ID, &tx.OrgID, &tx.WalletID, &tx.TxType, &tx.Chain,
|
||||
&tx.ToAddress, &tx.Amount, &tx.Token, &tx.TxHash, &tx.Status,
|
||||
&tx.InitiatedBy, &tx.CreatedAt, &tx.SignedAt, &tx.BroadcastAt); err != nil {
|
||||
continue
|
||||
}
|
||||
txs = append(txs, tx)
|
||||
}
|
||||
if txs == nil {
|
||||
txs = []db.Transaction{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, txs)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTransaction(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
txID := urlParam(r, "id")
|
||||
|
||||
var tx db.Transaction
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT id, org_id, wallet_id, tx_type, chain, to_address, amount, token,
|
||||
tx_hash, signature_r, signature_s, signature_eddsa,
|
||||
status, initiated_by, approved_by, rejected_by, rejection_reason,
|
||||
created_at, signed_at, broadcast_at
|
||||
FROM transactions WHERE id = $1 AND org_id = $2`, txID, orgID).
|
||||
Scan(&tx.ID, &tx.OrgID, &tx.WalletID, &tx.TxType, &tx.Chain,
|
||||
&tx.ToAddress, &tx.Amount, &tx.Token, &tx.TxHash,
|
||||
&tx.SignatureR, &tx.SignatureS, &tx.SignatureEdDSA,
|
||||
&tx.Status, &tx.InitiatedBy, &tx.ApprovedBy, &tx.RejectedBy, &tx.RejectionReason,
|
||||
&tx.CreatedAt, &tx.SignedAt, &tx.BroadcastAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "transaction not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, tx)
|
||||
}
|
||||
|
||||
func (s *Server) handleApproveTransaction(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
userID := getUserID(r.Context())
|
||||
txID := urlParam(r, "id")
|
||||
|
||||
var status string
|
||||
var approvedBy []string
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`UPDATE transactions
|
||||
SET approved_by = array_append(approved_by, $1::uuid)
|
||||
WHERE id = $2 AND org_id = $3 AND status = 'pending_approval'
|
||||
RETURNING status, approved_by`, userID, txID, orgID).
|
||||
Scan(&status, &approvedBy)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "transaction not found or not pending approval")
|
||||
return
|
||||
}
|
||||
|
||||
policies, _ := s.loadPolicies(r.Context(), orgID, nil)
|
||||
requiredApprovers := 1
|
||||
for _, p := range policies {
|
||||
if p.RequiredApprovers > requiredApprovers {
|
||||
requiredApprovers = p.RequiredApprovers
|
||||
}
|
||||
}
|
||||
|
||||
if len(approvedBy) >= requiredApprovers {
|
||||
s.db.Pool.Exec(r.Context(),
|
||||
`UPDATE transactions SET status = 'approved' WHERE id = $1`, txID)
|
||||
go s.signAndBroadcast(txID, orgID)
|
||||
}
|
||||
|
||||
s.fireWebhook(r.Context(), orgID, "tx.approved", map[string]string{
|
||||
"tx_id": txID,
|
||||
"approved_by": userID,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "approved"})
|
||||
}
|
||||
|
||||
func (s *Server) handleRejectTransaction(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
userID := getUserID(r.Context())
|
||||
txID := urlParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
tag, err := s.db.Pool.Exec(r.Context(),
|
||||
`UPDATE transactions SET status = 'rejected', rejected_by = $1, rejection_reason = $2
|
||||
WHERE id = $3 AND org_id = $4 AND status = 'pending_approval'`,
|
||||
userID, req.Reason, txID, orgID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
writeError(w, http.StatusNotFound, "transaction not found or not pending")
|
||||
return
|
||||
}
|
||||
|
||||
s.fireWebhook(r.Context(), orgID, "tx.rejected", map[string]string{
|
||||
"tx_id": txID,
|
||||
"reason": req.Reason,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "rejected"})
|
||||
}
|
||||
|
||||
func (s *Server) signAndBroadcast(txID, orgID string) {
|
||||
ctx := context.Background()
|
||||
|
||||
var walletDBID string
|
||||
var rawTx []byte
|
||||
err := s.db.Pool.QueryRow(ctx,
|
||||
`SELECT wallet_id, raw_tx FROM transactions WHERE id = $1`, txID).
|
||||
Scan(&walletDBID, &rawTx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var mpcWalletID string
|
||||
err = s.db.Pool.QueryRow(ctx,
|
||||
`SELECT wallet_id FROM wallets WHERE id = $1`, walletDBID).
|
||||
Scan(&mpcWalletID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.db.Pool.Exec(ctx, `UPDATE transactions SET status = 'signing' WHERE id = $1`, txID)
|
||||
|
||||
result, err := s.mpc.TriggerSign(mpcWalletID, rawTx)
|
||||
if err != nil {
|
||||
s.db.Pool.Exec(ctx, `UPDATE transactions SET status = 'failed' WHERE id = $1`, txID)
|
||||
s.fireWebhook(ctx, orgID, "tx.failed", map[string]string{"tx_id": txID, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
s.db.Pool.Exec(ctx,
|
||||
`UPDATE transactions SET status = 'signed', signature_r = $1, signature_s = $2,
|
||||
signature_eddsa = $3, signed_at = $4 WHERE id = $5`,
|
||||
result.R, result.S, result.Signature, now, txID)
|
||||
|
||||
s.fireWebhook(ctx, orgID, "tx.signed", map[string]string{"tx_id": txID})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user