Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions frontend/app/api/admin/actions/route.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
129 changes: 129 additions & 0 deletions frontend/app/api/admin/actions/route.ts
Original file line number Diff line number Diff line change
@@ -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=<poolId>&callerAddress=<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 }
)
}
}
3 changes: 3 additions & 0 deletions frontend/app/dashboard/group/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -115,6 +116,8 @@ export default function GroupPage({
groupId={id}
creatorAddress={pool.creator_address}
/>
{/* Admin actions log — visible to all pool members */}
<AdminActionsLog groupId={id} />
</div>

<div className="space-y-6">
Expand Down
Loading
Loading