diff --git a/frontend/app/api/admin/actions/route.test.ts b/frontend/app/api/admin/actions/route.test.ts new file mode 100644 index 0000000..a9c23e8 --- /dev/null +++ b/frontend/app/api/admin/actions/route.test.ts @@ -0,0 +1,75 @@ +import { test } from "node:test" +import assert from "node:assert" + +// ── Mock Logic matching route.ts implementation ───────────────────────────── + +function isAuthorized( + callerAddress: string, + pool: { creator_address: string }, + members: string[] +): boolean { + const caller = callerAddress.toLowerCase() + return ( + pool.creator_address.toLowerCase() === caller || + members.map(m => m.toLowerCase()).includes(caller) + ) +} + +function getAuthErrorCode( + poolId: string | null, + callerAddress: string | null, + pool: { creator_address: string } | null, + members: string[] +): number | null { + if (!poolId) return 400 + if (!callerAddress) return 400 + if (!pool) return 404 + if (!isAuthorized(callerAddress, pool, members)) return 403 + return null // Authorized +} + +// ── Test Cases ────────────────────────────────────────────────────────────── + +test("admin actions auth — returns 400 when poolId is missing", () => { + const code = getAuthErrorCode(null, "GABC", { creator_address: "GCREATOR" }, []) + assert.strictEqual(code, 400) +}) + +test("admin actions auth — returns 400 when callerAddress is missing", () => { + const code = getAuthErrorCode("p1", null, { creator_address: "GCREATOR" }, []) + assert.strictEqual(code, 400) +}) + +test("admin actions auth — returns 404 when pool is not found", () => { + const code = getAuthErrorCode("p1", "GABC", null, []) + assert.strictEqual(code, 404) +}) + +test("admin actions auth — returns 403 when caller is not creator and not a member", () => { + const pool = { creator_address: "GCREATOR" } + const members = ["GMEMBER1", "GMEMBER2"] + const code = getAuthErrorCode("p1", "GINTRUDER", pool, members) + assert.strictEqual(code, 403) +}) + +test("admin actions auth — returns null (authorized) when caller is creator", () => { + const pool = { creator_address: "GCREATOR" } + const members = ["GMEMBER1", "GMEMBER2"] + const code = getAuthErrorCode("p1", "GCREATOR", pool, members) + assert.strictEqual(code, null) +}) + +test("admin actions auth — returns null (authorized) when caller is a pool member", () => { + const pool = { creator_address: "GCREATOR" } + const members = ["GMEMBER1", "GMEMBER2"] + const code = getAuthErrorCode("p1", "gmember2", pool, members) + assert.strictEqual(code, null) +}) + +test("admin actions auth — case-insensitive address comparison", () => { + const pool = { creator_address: "GCREATOR" } + const members = ["GMEMBER1"] + assert.strictEqual(isAuthorized("gcreator", pool, members), true) + assert.strictEqual(isAuthorized("gmember1", pool, members), true) + assert.strictEqual(isAuthorized("gmember2", pool, members), false) +}) diff --git a/frontend/app/api/admin/actions/route.ts b/frontend/app/api/admin/actions/route.ts new file mode 100644 index 0000000..8b50e40 --- /dev/null +++ b/frontend/app/api/admin/actions/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from "next/server" +import { supabase } from "@/lib/supabase" +import { readLimiter, writeLimiter } from "@/lib/rate-limit" + +/** + * POST /api/admin/actions + * Logs an administrative action if the transaction hash hasn't been logged yet. + */ +export async function POST(req: NextRequest) { + try { + const limited = writeLimiter(req) + if (limited) return limited + + const body = await req.json() + const { poolId, adminAddress, actionType, targetAddress, metadata, txHash } = body + + if (!poolId || !adminAddress || !actionType) { + return NextResponse.json( + { error: "Missing required fields: poolId, adminAddress, actionType" }, + { status: 400 } + ) + } + + // De-duplication check: avoid double-logging a confirmed transaction + if (txHash) { + const { data: existing } = await supabase + .from("admin_actions") + .select("id") + .eq("tx_hash", txHash) + .maybeSingle() + + if (existing) { + return NextResponse.json({ success: true, message: "Action already logged", id: existing.id }) + } + } + + const { data, error } = await supabase + .from("admin_actions") + .insert({ + pool_id: poolId, + admin_address: adminAddress.toLowerCase(), + action_type: actionType, + target_address: targetAddress ? targetAddress.toLowerCase() : null, + metadata: metadata || {}, + tx_hash: txHash || null, + }) + .select() + .single() + + if (error) { + // Handle race condition where two inserts for same tx_hash happen simultaneously + if (error.code === "23505") { + return NextResponse.json({ success: true, message: "Action already logged (race condition)" }) + } + throw error + } + + return NextResponse.json({ success: true, data }, { status: 201 }) + } catch (error) { + console.error("Failed to log admin action:", error) + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to log admin action" }, + { status: 500 } + ) + } +} + +/** + * GET /api/admin/actions?poolId=&callerAddress= + * Returns all admin actions for the given pool. + * Only readable by the pool creator or any member of the pool. + */ +export async function GET(req: NextRequest) { + try { + const limited = readLimiter(req) + if (limited) return limited + + const poolId = req.nextUrl.searchParams.get("poolId") + if (!poolId) { + return NextResponse.json({ error: "poolId is required" }, { status: 400 }) + } + + const callerAddress = req.nextUrl.searchParams.get("callerAddress") + if (!callerAddress) { + return NextResponse.json({ error: "callerAddress is required" }, { status: 400 }) + } + + const { data: pool, error: poolErr } = await supabase + .from("pools") + .select("id, creator_address") + .eq("id", poolId) + .single() + + if (poolErr || !pool) { + return NextResponse.json({ error: "Pool not found" }, { status: 404 }) + } + + const isCreator = pool.creator_address.toLowerCase() === callerAddress.toLowerCase() + + const { data: member, error: memberErr } = await supabase + .from("pool_members") + .select("id") + .eq("pool_id", poolId) + .eq("member_address", callerAddress.toLowerCase()) + .maybeSingle() + + if (!isCreator && !member) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const { data: actions, error: actErr } = await supabase + .from("admin_actions") + .select("*") + .eq("pool_id", poolId) + .order("created_at", { ascending: false }) + + if (actErr) { + return NextResponse.json({ error: "Failed to fetch admin actions" }, { status: 500 }) + } + + return NextResponse.json({ actions: actions ?? [] }) + } catch (error) { + console.error("Failed to fetch admin actions:", error) + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch admin actions" }, + { status: 500 } + ) + } +} diff --git a/frontend/app/dashboard/group/[id]/page.tsx b/frontend/app/dashboard/group/[id]/page.tsx index d79bdc7..85d2292 100644 --- a/frontend/app/dashboard/group/[id]/page.tsx +++ b/frontend/app/dashboard/group/[id]/page.tsx @@ -8,6 +8,7 @@ import { GroupActivity } from "@/components/group/group-activity"; import { GroupActions } from "@/components/group/group-actions"; import { YieldDashboard } from "@/components/group/yield-dashboard"; import { AdminAuditLog } from "@/components/group/admin-audit-log"; +import { AdminActionsLog } from "@/components/group/admin-actions-log"; import { Button } from "@/components/ui/button"; import { ArrowLeft } from "lucide-react"; import Link from "next/link"; @@ -115,6 +116,8 @@ export default function GroupPage({ groupId={id} creatorAddress={pool.creator_address} /> + {/* Admin actions log — visible to all pool members */} +
diff --git a/frontend/components/group/admin-actions-log.tsx b/frontend/components/group/admin-actions-log.tsx new file mode 100644 index 0000000..1ccac5e --- /dev/null +++ b/frontend/components/group/admin-actions-log.tsx @@ -0,0 +1,233 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { Card } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + ShieldOff, + Shield, + UserPlus, + UserMinus, + Settings, + AlertTriangle, + Loader2, + RefreshCw, + ExternalLink, +} from "lucide-react" +import { useStellar } from "@/components/web3-provider" +import { formatRelativeTime, formatExactDateTime } from "@/lib/utils" +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip" + +interface AdminAction { + id: string + pool_id: string + admin_address: string + action_type: string + target_address: string | null + metadata: Record + tx_hash: string | null + created_at: string +} + +interface AdminActionsLogProps { + groupId: string +} + +export function AdminActionsLog({ groupId }: AdminActionsLogProps) { + const { address } = useStellar() + const [actions, setActions] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const fetchActions = useCallback(async () => { + if (!address) { + setError("Connect your wallet to view admin activity") + setActions([]) + return + } + + setLoading(true) + setError(null) + try { + const res = await fetch(`/api/admin/actions?poolId=${groupId}&callerAddress=${address}`) + if (res.status === 403) { + setError("Admin activity log is only visible to pool members") + setActions([]) + return + } + if (!res.ok) { + throw new Error("Failed to fetch admin actions") + } + const data = await res.json() + setActions(data.actions || []) + } catch (e: any) { + setError(e.message || "An error occurred") + } finally { + setLoading(false) + } + }, [groupId, address]) + + useEffect(() => { + fetchActions() + }, [fetchActions]) + + const formatAddress = (addr: string | null) => { + if (!addr) return "" + return `${addr.slice(0, 8)}…${addr.slice(-6)}` + } + + const getActionDetails = (action: AdminAction) => { + switch (action.action_type) { + case "pause": + return { + title: "Pool Paused", + description: "Admin paused the pool. Transactions are disabled.", + icon: ShieldOff, + iconColor: "text-destructive", + bgColor: "bg-destructive/10", + } + case "unpause": + return { + title: "Pool Unpaused", + description: "Admin unpaused the pool. Transactions are re-enabled.", + icon: Shield, + iconColor: "text-green-600", + bgColor: "bg-green-600/10", + } + case "add_member": + return { + title: "Member Added", + description: `Added address ${formatAddress(action.target_address)} to the pool.`, + icon: UserPlus, + iconColor: "text-primary", + bgColor: "bg-primary/10", + } + case "remove_member": + return { + title: "Member Removed", + description: `Removed address ${formatAddress(action.target_address)} from the pool.`, + icon: UserMinus, + iconColor: "text-amber-600", + bgColor: "bg-amber-600/10", + } + case "emergency_withdraw": + return { + title: "Emergency Withdraw", + description: `Triggered emergency withdrawal to recipient ${formatAddress(action.target_address)}.`, + icon: AlertTriangle, + iconColor: "text-destructive", + bgColor: "bg-destructive/10", + } + case "set_treasury": + return { + title: "Treasury Updated", + description: `Updated treasury address to ${formatAddress(action.target_address)}.`, + icon: Settings, + iconColor: "text-muted-foreground", + bgColor: "bg-muted", + } + default: + return { + title: action.action_type.replace("_", " "), + description: `Admin action: ${action.action_type}`, + icon: Settings, + iconColor: "text-muted-foreground", + bgColor: "bg-muted", + } + } + } + + return ( + +
+
+

Admin Activity

+

Log of admin-only pool actions

+
+ +
+ + {loading && actions.length === 0 && ( +
+ +
+ )} + + {error && ( +

{error}

+ )} + + {!loading && !error && actions.length === 0 && ( +

No admin actions recorded yet.

+ )} + + {actions.length > 0 && ( +
+ {actions.map((action) => { + const details = getActionDetails(action) + const Icon = details.icon + return ( +
+
+ +
+
+
+

{details.title}

+ + Admin: {formatAddress(action.admin_address)} + +
+

{details.description}

+
+ + + + + {formatExactDateTime(action.created_at)} + + {action.tx_hash && ( + <> + + + Tx: {action.tx_hash.slice(0, 8)}… + + + + )} +
+
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/components/group/group-actions.tsx b/frontend/components/group/group-actions.tsx index 6631a15..33eeac9 100644 --- a/frontend/components/group/group-actions.tsx +++ b/frontend/components/group/group-actions.tsx @@ -88,6 +88,32 @@ async function logActivity( }); } catch {} } + +async function logAdminAction( + poolId: string, + adminAddress: string, + actionType: string, + targetAddress: string | null, + txHash: string | null, + metadata?: Record, +) { + try { + await fetch("/api/admin/actions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + poolId, + adminAddress, + actionType, + targetAddress, + txHash, + metadata: metadata || {}, + }), + }); + } catch (err) { + console.error("Failed to log admin action:", err); + } +} export function GroupActions({ groupId, poolAddress, @@ -387,7 +413,10 @@ export function GroupActions({ if (!address) return setError("Please connect your wallet first"); if (isPending) return setError("Contract not yet deployed."); try { - await pausePool.pause(); + const txHash = await pausePool.pause(); + if (txHash) { + await logAdminAction(groupId, address, "pause", null, txHash); + } setSuccessMsg("Pool paused successfully."); onPauseChange?.(); } catch (e: any) { @@ -401,7 +430,10 @@ export function GroupActions({ if (!address) return setError("Please connect your wallet first"); if (isPending) return setError("Contract not yet deployed."); try { - await unpausePool.unpause(); + const txHash = await unpausePool.unpause(); + if (txHash) { + await logAdminAction(groupId, address, "unpause", null, txHash); + } setSuccessMsg("Pool unpaused successfully."); onPauseChange?.(); } catch (e: any) { @@ -423,6 +455,7 @@ export function GroupActions({ const txHash = await addPoolMember.addMember(newMember.trim().toUpperCase()); if (txHash) { await logActivity(groupId, "member_added", address, null, txHash, newMember.trim().toUpperCase()); + await logAdminAction(groupId, address, "add_member", newMember.trim().toUpperCase(), txHash); setSuccessMsg("Member added successfully."); setNewMember(""); await refreshMembers(); @@ -444,6 +477,7 @@ export function GroupActions({ const txHash = await removePoolMember.removeMember(memberToRemove); if (txHash) { await logActivity(groupId, "member_removed", address, null, txHash, memberToRemove); + await logAdminAction(groupId, address, "remove_member", memberToRemove, txHash); setSuccessMsg("Member removed successfully."); setMemberToRemove(null); await refreshMembers(); diff --git a/frontend/lib/supabase.ts b/frontend/lib/supabase.ts index 5ac051d..09b9661 100644 --- a/frontend/lib/supabase.ts +++ b/frontend/lib/supabase.ts @@ -302,6 +302,37 @@ export type Database = { read?: boolean } } + admin_actions: { + Row: { + id: string + pool_id: string + admin_address: string + action_type: string + target_address: string | null + metadata: Record + tx_hash: string | null + created_at: string + } + Insert: { + id?: string + pool_id: string + admin_address: string + action_type: string + target_address?: string | null + metadata?: Record + tx_hash?: string | null + created_at?: string + } + Update: { + pool_id?: string + admin_address?: string + action_type?: string + target_address?: string | null + metadata?: Record + tx_hash?: string | null + created_at?: string + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index c208d74..b8f0ad6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "dev": "next dev --webpack", "lint": "next lint", "start": "next start", - "test:unit": "tsx --test lib/csv-export.test.ts lib/analytics.test.ts lib/pool-health.test.ts lib/form-validation.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts", + "test:unit": "tsx --test lib/csv-export.test.ts lib/analytics.test.ts lib/pool-health.test.ts lib/form-validation.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts app/api/admin/actions/route.test.ts", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report" diff --git a/supabase/migrations/20260629000000_admin_actions.sql b/supabase/migrations/20260629000000_admin_actions.sql new file mode 100644 index 0000000..6d1995c --- /dev/null +++ b/supabase/migrations/20260629000000_admin_actions.sql @@ -0,0 +1,22 @@ +-- Migration: Create admin_actions table for tracking administrative activities +CREATE TABLE IF NOT EXISTS public.admin_actions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pool_id UUID NOT NULL REFERENCES public.pools(id) ON DELETE CASCADE, + admin_address TEXT NOT NULL, + action_type TEXT NOT NULL, + target_address TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + tx_hash TEXT UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Index for querying actions by pool +CREATE INDEX IF NOT EXISTS idx_admin_actions_pool_id ON public.admin_actions(pool_id); + +-- Enable RLS +ALTER TABLE public.admin_actions ENABLE ROW LEVEL SECURITY; + +-- Select policy: readable by anyone (anon key allowed) +CREATE POLICY "admin_actions_select_public" + ON public.admin_actions FOR SELECT + USING (true);