diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index d738cd7..85eaf3c 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -15,6 +15,8 @@ const AlertsPage = lazy(() => import("@/pages/alerts/AlertsPage")); const SpendPage = lazy(() => import("@/pages/spend/SpendPage")); const OnchainPage = lazy(() => import("@/pages/onchain/OnchainPage")); const CredentialsPage = lazy(() => import("@/pages/credentials/CredentialsPage")); +const ExchangeConfigPage = lazy(() => import("@/pages/exchange-config/ExchangeConfigPage")); +const ZkProofsPage = lazy(() => import("@/pages/zk-proofs/ZkProofsPage")); const LoginPage = lazy(() => import("@/pages/login/LoginPage")); const LandingPage = lazy(() => import("@/pages/landing/LandingPage")); const DocsLayout = lazy(() => import("@/pages/docs/DocsLayout")); @@ -187,6 +189,22 @@ export default function App() { } /> + }> + + + } + /> + }> + + + } + /> { + return apiFetch("/exchange-config"); +} + +export function addExchange( + payload: AddExchangePayload, +): Promise<{ success: boolean }> { + return apiFetch("/exchange-config", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function removeExchange( + id: string, +): Promise<{ success: boolean }> { + return apiFetch(`/exchange-config/${id}`, { method: "DELETE" }); +} + +export function updateEndpointToggle( + payload: UpdateEndpointPayload, +): Promise<{ success: boolean }> { + return apiFetch("/exchange-config/endpoint", { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export function updateExchangeLimits( + payload: UpdateExchangeLimitsPayload, +): Promise<{ success: boolean }> { + return apiFetch(`/exchange-config/${payload.exchange_id}/limits`, { + method: "PUT", + body: JSON.stringify(payload), + }); +} diff --git a/dashboard/src/api/endpoints/proofs.ts b/dashboard/src/api/endpoints/proofs.ts new file mode 100644 index 0000000..7fb5cdf --- /dev/null +++ b/dashboard/src/api/endpoints/proofs.ts @@ -0,0 +1,31 @@ +import { apiFetch } from "../client"; +import { API_BASE } from "@/lib/constants"; +import type { + ProofGeneratePayload, + ProofGenerateResponse, + ProofJobStatusResponse, + ProofHistoryResponse, +} from "../types"; + +export function generateProof( + payload: ProofGeneratePayload, +): Promise { + return apiFetch( + `/proof/generate?from=${payload.from_date}&to=${payload.to_date}`, + { method: "POST" }, + ); +} + +export function fetchProofJobStatus( + jobId: string, +): Promise { + return apiFetch(`/proof/${jobId}`); +} + +export function fetchProofHistory(): Promise { + return apiFetch("/proof/history"); +} + +export function getProofDownloadUrl(proofId: string): string { + return `${API_BASE}/proof/${proofId}/download`; +} diff --git a/dashboard/src/api/endpoints/settings.ts b/dashboard/src/api/endpoints/settings.ts new file mode 100644 index 0000000..69a5ed0 --- /dev/null +++ b/dashboard/src/api/endpoints/settings.ts @@ -0,0 +1,79 @@ +import { apiFetch, TOKEN_KEY } from "../client"; +import { API_BASE } from "@/lib/constants"; +import type { + ChangePasswordPayload, + VaultBackupResponse, + NetworkIsolationResponse, + SignerModeResponse, + SignerModeType, + FactoryResetResponse, +} from "../types"; + +export function changePassword( + payload: ChangePasswordPayload, +): Promise<{ success: boolean }> { + return apiFetch("/settings/password", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function fetchVaultBackupUrl(): Promise { + return apiFetch("/settings/vault/backup"); +} + +export async function restoreVault( + file: File, +): Promise<{ success: boolean }> { + const token = localStorage.getItem(TOKEN_KEY); + const form = new FormData(); + form.append("file", file); + + const res = await fetch(`${API_BASE}/settings/vault/restore`, { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: form, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(body.error ?? "Restore failed"); + } + + return res.json(); +} + +export function fetchNetworkIsolation(): Promise { + return apiFetch("/settings/network-isolation"); +} + +export function updateNetworkIsolation( + enabled: boolean, +): Promise { + return apiFetch("/settings/network-isolation", { + method: "PUT", + body: JSON.stringify({ enabled }), + }); +} + +export function fetchSignerMode(): Promise { + return apiFetch("/settings/signer-mode"); +} + +export function updateSignerMode( + mode: SignerModeType, +): Promise { + return apiFetch("/settings/signer-mode", { + method: "PUT", + body: JSON.stringify({ mode }), + }); +} + +export function factoryReset( + confirmToken: string, +): Promise { + return apiFetch("/settings/factory-reset", { + method: "POST", + body: JSON.stringify({ confirm: confirmToken }), + }); +} diff --git a/dashboard/src/api/types.ts b/dashboard/src/api/types.ts index 10a4124..369ea01 100644 --- a/dashboard/src/api/types.ts +++ b/dashboard/src/api/types.ts @@ -253,6 +253,119 @@ export interface PolicyQuickConfigPayload { rate_limit_rpm: number; } +// --- Exchange Config --- +export type EndpointPermission = "always_allowed" | "toggleable" | "permanently_blocked"; + +export interface ExchangeEndpoint { + pattern: string; + method: "GET" | "POST" | "PUT" | "DELETE"; + permission: EndpointPermission; + enabled: boolean; + description: string; + max_order_value?: number; + daily_volume_cap?: number; +} + +export interface Exchange { + id: string; + name: string; + base_url: string; + auth_pattern: string; + status: "connected" | "disconnected" | "error"; + endpoints: ExchangeEndpoint[]; + volume: { today_volume_usd: number; daily_cap_usd: number }; + limits: { max_order_value_usd: number; daily_volume_cap_usd: number }; +} + +export interface ExchangeConfigResponse { + exchanges: Exchange[]; +} + +export interface AddExchangePayload { + name: string; + base_url: string; + auth_pattern: string; + blocked_endpoints: string[]; +} + +export interface UpdateEndpointPayload { + exchange_id: string; + endpoint_pattern: string; + enabled: boolean; + max_order_value?: number; + daily_volume_cap?: number; +} + +export interface UpdateExchangeLimitsPayload { + exchange_id: string; + max_order_value_usd: number; + daily_volume_cap_usd: number; +} + +// --- ZK Proofs --- +export type ProofJobStatus = "pending" | "generating" | "completed" | "failed"; + +export interface ProofGeneratePayload { + from_date: string; + to_date: string; +} + +export interface ProofGenerateResponse { + job_id: string; +} + +export interface ProofJobStatusResponse { + job_id: string; + status: ProofJobStatus; + progress_pct: number; + error?: string; +} + +export interface ProofResult { + id: string; + job_id: string; + generated_at: number; + from_date: string; + to_date: string; + entries_covered: number; + merkle_root: string; + policy_hash: string; + spend_status: "within_budget" | "over_budget" | "no_data"; + download_url: string; +} + +export interface ProofHistoryResponse { + proofs: ProofResult[]; +} + +// --- Settings (extended) --- +export interface ChangePasswordPayload { + current_password: string; + new_password: string; + confirm_password: string; +} + +export interface VaultBackupResponse { + download_url: string; + filename: string; +} + +export interface NetworkIsolationResponse { + enabled: boolean; + status: "active" | "inactive" | "error"; +} + +export type SignerModeType = "secure_enclave" | "encrypted_keyfile" | "threshold"; + +export interface SignerModeResponse { + current: SignerModeType; + available: SignerModeType[]; +} + +export interface FactoryResetResponse { + success: boolean; +} + // --- Generic --- export interface ApiError { error: string; diff --git a/dashboard/src/components/layout/Shell.tsx b/dashboard/src/components/layout/Shell.tsx index 87a5459..dc6fa3e 100644 --- a/dashboard/src/components/layout/Shell.tsx +++ b/dashboard/src/components/layout/Shell.tsx @@ -14,6 +14,8 @@ const routeTitles: Record = { [ROUTES.SPEND]: "Spend Analytics", [ROUTES.ONCHAIN]: "Onchain Permits", [ROUTES.CREDENTIALS]: "Credentials", + [ROUTES.EXCHANGE_CONFIG]: "Exchange Config", + [ROUTES.ZK_PROOFS]: "ZK Proofs", }; const routeSubtitles: Record = { @@ -23,6 +25,8 @@ const routeSubtitles: Record = { [ROUTES.SPEND]: "Budget tracking and daily spend breakdown", [ROUTES.ONCHAIN]: "Contract whitelist, permit history, and signer status", [ROUTES.CREDENTIALS]: "Manage API keys for external services", + [ROUTES.EXCHANGE_CONFIG]: "Configure exchange connections, endpoints, and volume limits", + [ROUTES.ZK_PROOFS]: "Generate and manage zero-knowledge compliance proofs", }; export function Shell() { diff --git a/dashboard/src/components/layout/Sidebar.tsx b/dashboard/src/components/layout/Sidebar.tsx index fa2b665..6da8f17 100644 --- a/dashboard/src/components/layout/Sidebar.tsx +++ b/dashboard/src/components/layout/Sidebar.tsx @@ -6,6 +6,7 @@ import { ROUTES } from "@/lib/constants"; import { LayoutDashboard, Key, + ArrowLeftRight, Sliders, FileText, BarChart3, @@ -15,6 +16,7 @@ import { ChevronRight, LogOut, Shield, + ShieldCheck, } from "lucide-react"; interface SidebarProps { collapsed: boolean; @@ -35,10 +37,12 @@ interface NavItemData { const mainNavItems: NavItemData[] = [ { to: ROUTES.HOME, label: "Dashboard", icon: }, { to: ROUTES.CREDENTIALS, label: "Credentials", icon: }, + { to: ROUTES.EXCHANGE_CONFIG, label: "Exchange Config", icon: }, { label: "Policies", icon: , disabled: true }, { label: "Audit Log", icon: , disabled: true }, { to: ROUTES.SPEND, label: "Spend", icon: }, { to: ROUTES.ONCHAIN, label: "Onchain", icon: }, + { to: ROUTES.ZK_PROOFS, label: "ZK Proofs", icon: }, ]; function getSecondaryNavItems(alertCount: number): NavItemData[] { diff --git a/dashboard/src/hooks/use-exchange-config.ts b/dashboard/src/hooks/use-exchange-config.ts new file mode 100644 index 0000000..a5dcef8 --- /dev/null +++ b/dashboard/src/hooks/use-exchange-config.ts @@ -0,0 +1,101 @@ +import { useState, useCallback, useMemo } from "react"; +import { useFetch } from "./use-fetch"; +import { + fetchExchangeConfig, + addExchange, + removeExchange, + updateEndpointToggle, + updateExchangeLimits, +} from "@/api/endpoints/exchange-config"; +import type { + Exchange, + AddExchangePayload, + UpdateEndpointPayload, + UpdateExchangeLimitsPayload, +} from "@/api/types"; + +interface UseExchangeConfigReturn { + exchanges: Exchange[]; + loading: boolean; + error: Error | null; + add: (payload: AddExchangePayload) => Promise; + remove: (id: string) => Promise; + toggleEndpoint: (payload: UpdateEndpointPayload) => Promise; + updateLimits: (payload: UpdateExchangeLimitsPayload) => Promise; + refetch: () => void; +} + +export function useExchangeConfig(): UseExchangeConfigReturn { + const { data, loading, error, refetch } = useFetch(fetchExchangeConfig); + + const [optimisticRemoved, setOptimisticRemoved] = useState>( + new Set(), + ); + + const exchanges = useMemo(() => { + if (!data) return []; + return data.exchanges.filter((e) => !optimisticRemoved.has(e.id)); + }, [data, optimisticRemoved]); + + const add = useCallback( + async (payload: AddExchangePayload): Promise => { + try { + await addExchange(payload); + refetch(); + return true; + } catch { + return false; + } + }, + [refetch], + ); + + const remove = useCallback(async (id: string) => { + setOptimisticRemoved((prev) => new Set(prev).add(id)); + try { + await removeExchange(id); + } catch { + setOptimisticRemoved((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + } + }, []); + + const toggleEndpoint = useCallback( + async (payload: UpdateEndpointPayload) => { + try { + await updateEndpointToggle(payload); + refetch(); + } catch { + // silent fail — refetch to restore server state + refetch(); + } + }, + [refetch], + ); + + const updateLimitsHandler = useCallback( + async (payload: UpdateExchangeLimitsPayload) => { + try { + await updateExchangeLimits(payload); + refetch(); + } catch { + refetch(); + } + }, + [refetch], + ); + + return { + exchanges, + loading, + error, + add, + remove, + toggleEndpoint, + updateLimits: updateLimitsHandler, + refetch, + }; +} diff --git a/dashboard/src/hooks/use-proof-generation.ts b/dashboard/src/hooks/use-proof-generation.ts new file mode 100644 index 0000000..5570d90 --- /dev/null +++ b/dashboard/src/hooks/use-proof-generation.ts @@ -0,0 +1,78 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { generateProof, fetchProofJobStatus } from "@/api/endpoints/proofs"; +import { POLLING_INTERVALS } from "@/lib/constants"; +import type { ProofJobStatus } from "@/api/types"; + +interface UseProofGenerationReturn { + generating: boolean; + jobId: string | null; + progress: number; + status: ProofJobStatus | null; + error: string | null; + generate: (from: string, to: string) => Promise; + reset: () => void; +} + +export function useProofGeneration(): UseProofGenerationReturn { + const [jobId, setJobId] = useState(null); + const [progress, setProgress] = useState(0); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // Poll for job status + useEffect(() => { + if (!jobId || status === "completed" || status === "failed") return; + + const poll = async () => { + try { + const res = await fetchProofJobStatus(jobId); + if (!mountedRef.current) return; + setProgress(res.progress_pct); + setStatus(res.status); + if (res.error) setError(res.error); + } catch { + // Swallow polling errors + } + }; + + const id = setInterval(poll, POLLING_INTERVALS.PROOF_JOB); + poll(); // immediate first poll + return () => clearInterval(id); + }, [jobId, status]); + + const generating = status === "pending" || status === "generating"; + + const generate = useCallback(async (from: string, to: string) => { + setError(null); + setProgress(0); + setStatus("pending"); + try { + const res = await generateProof({ from_date: from, to_date: to }); + if (mountedRef.current) { + setJobId(res.job_id); + } + } catch (err) { + if (mountedRef.current) { + setError(err instanceof Error ? err.message : "Failed to start proof generation"); + setStatus("failed"); + } + } + }, []); + + const reset = useCallback(() => { + setJobId(null); + setProgress(0); + setStatus(null); + setError(null); + }, []); + + return { generating, jobId, progress, status, error, generate, reset }; +} diff --git a/dashboard/src/lib/constants.ts b/dashboard/src/lib/constants.ts index 8deb35b..d2a0033 100644 --- a/dashboard/src/lib/constants.ts +++ b/dashboard/src/lib/constants.ts @@ -12,6 +12,8 @@ export const ROUTES = { DOCS_POLICIES: "/docs/policies", DOCS_SECURITY: "/docs/security", CREDENTIALS: "/credentials", + EXCHANGE_CONFIG: "/exchange-config", + ZK_PROOFS: "/zk-proofs", } as const; export const POLLING_INTERVALS = { @@ -19,6 +21,7 @@ export const POLLING_INTERVALS = { ALERTS: 30_000, SPEND: 60_000, ONCHAIN: 10_000, + PROOF_JOB: 2_500, } as const; /** Human-readable chain names for common EVM chain IDs */ diff --git a/dashboard/src/pages/exchange-config/AddExchangeModal.tsx b/dashboard/src/pages/exchange-config/AddExchangeModal.tsx new file mode 100644 index 0000000..e53cb43 --- /dev/null +++ b/dashboard/src/pages/exchange-config/AddExchangeModal.tsx @@ -0,0 +1,157 @@ +import { useState, useCallback, useEffect } from "react"; +import { X } from "lucide-react"; +import type { AddExchangePayload } from "@/api/types"; + +interface AddExchangeModalProps { + open: boolean; + onClose: () => void; + onSubmit: (payload: AddExchangePayload) => Promise; +} + +const AUTH_PATTERNS = ["Bearer Token", "HMAC Signature", "API Key Header"] as const; + +export function AddExchangeModal({ open, onClose, onSubmit }: AddExchangeModalProps) { + const [name, setName] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [authPattern, setAuthPattern] = useState(AUTH_PATTERNS[0]); + const [blockedEndpoints, setBlockedEndpoints] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (open) { + setName(""); + setBaseUrl(""); + setAuthPattern(AUTH_PATTERNS[0]); + setBlockedEndpoints(""); + } + }, [open]); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + const canSubmit = name.trim().length > 0 && baseUrl.trim().length > 0; + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit || submitting) return; + setSubmitting(true); + const blocked = blockedEndpoints + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const ok = await onSubmit({ + name: name.trim(), + base_url: baseUrl.trim(), + auth_pattern: authPattern, + blocked_endpoints: blocked, + }); + setSubmitting(false); + if (ok) onClose(); + }, + [name, baseUrl, authPattern, blockedEndpoints, canSubmit, submitting, onSubmit, onClose], + ); + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+

Add Exchange

+ +
+ +
+
+ + setName(e.target.value)} + placeholder="e.g. Binance" + className="w-full rounded-lg border border-border bg-surface-input px-3 py-2 text-sm text-text placeholder:text-text-tertiary/50 focus:border-brand/50 focus:outline-none focus:ring-1 focus:ring-brand/20" + /> +
+ +
+ + setBaseUrl(e.target.value)} + placeholder="https://api.binance.com" + className="w-full rounded-lg border border-border bg-surface-input px-3 py-2 font-mono text-sm text-text placeholder:text-text-tertiary/50 focus:border-brand/50 focus:outline-none focus:ring-1 focus:ring-brand/20" + /> +
+ +
+ + +
+ +
+ +