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
5 changes: 5 additions & 0 deletions .changeset/curvy-reporters-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@anticapture/dashboard": patch
---

Move data inconsistency report trigger from Help dropdown to inline Flag icon in each panel. The panel name is now structurally correct (it's literally where you clicked), removing the need for the dropdown, `report-panels.ts` constants, the `section` field, and the server-side allowlist.
4 changes: 4 additions & 0 deletions apps/dashboard/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ RESEND_API_KEY=
RESEND_FROM_EMAIL=onboarding@resend.dev
CONTACT_EMAIL=

# ClickUp public data-report integration (server-only)
CLICKUP_API_TOKEN=
CLICKUP_REPORT_LIST_ID=

# GitHub token for higher API rate limits (5000 req/hour vs 60 unauthenticated)
GITHUB_TOKEN=

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Metadata } from "next";
import { AttackProfitabilitySection } from "@/features/attack-profitability";
import { TheSectionLayout } from "@/shared/components";
import { SubSectionsContainer } from "@/shared/components/design-system/section";
import { ReportPanelButton } from "@/shared/components/report/ReportPanelButton";
import { PAGES_CONSTANTS } from "@/shared/constants/pages-constants";
import daoConfigByDaoId from "@/shared/dao-config";
import type { DaoIdEnum } from "@/shared/types/daos";
Expand Down Expand Up @@ -54,6 +55,7 @@ export default async function AttackProfitabilityPage({
title={PAGES_CONSTANTS.attackProfitability.title}
icon={<Crosshair2Icon className="section-layout-icon" />}
description={PAGES_CONSTANTS.attackProfitability.description}
headerAction={<ReportPanelButton panel="Attack Profitability" />}
>
<SubSectionsContainer>
<AttackProfitabilitySection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Metadata } from "next";
import { TokenDistributionSection } from "@/features/token-distribution";
import { TheSectionLayout } from "@/shared/components";
import { SubSectionsContainer } from "@/shared/components/design-system/section";
import { ReportPanelButton } from "@/shared/components/report/ReportPanelButton";
import { PAGES_CONSTANTS } from "@/shared/constants/pages-constants";
import daoConfigByDaoId from "@/shared/dao-config";
import type { DaoIdEnum } from "@/shared/types/daos";
Expand Down Expand Up @@ -54,6 +55,7 @@ export default async function TokenDistributionPage({
title={PAGES_CONSTANTS.tokenDistribution.title}
icon={<ArrowRightLeft className="section-layout-icon" />}
description={PAGES_CONSTANTS.tokenDistribution.description}
headerAction={<ReportPanelButton panel="Token Distribution" />}
>
<SubSectionsContainer>
<TokenDistributionSection daoId={daoIdEnum} />
Expand Down
18 changes: 14 additions & 4 deletions apps/dashboard/app/aave/stakeholders/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { TabButton } from "@/features/holders-and-delegates/components/TabButton
import { TokenHolders } from "@/features/holders-and-delegates/token-holder";
import { Footer } from "@/shared/components/design-system/footer";
import { SwitcherDate } from "@/shared/components";
import { ReportPanelButton } from "@/shared/components/report/ReportPanelButton";
import { DaoIdEnum } from "@/shared/types/daos";
import { TimeInterval } from "@/shared/types/enums";
import { HeaderDAOSidebar, HeaderSidebar, StickyPageHeader } from "@/widgets";
Expand Down Expand Up @@ -97,10 +98,19 @@ function AavePageContent() {
/>
))}
</div>
<SwitcherDate
defaultValue={days || defaultDays}
setTimeInterval={setDays}
/>
<div className="flex items-center gap-2">
<SwitcherDate
defaultValue={days || defaultDays}
setTimeInterval={setDays}
/>
<ReportPanelButton
panel={
activeTab === "delegates"
? "Delegates"
: "Token Holders"
}
/>
</div>
</div>
{activeTab === "delegates" ? (
<DelegationTable days={days || defaultDays} />
Expand Down
155 changes: 155 additions & 0 deletions apps/dashboard/app/api/report/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { NextRequest } from "next/server";

import { POST } from "./route";

const originalFetch = global.fetch;
const originalEnvironment = { ...process.env };

const createRequest = (
ip: string,
overrides = {},
headers: Record<string, string> = {},
) =>
new NextRequest("http://localhost:3000/api/report", {
method: "POST",
headers: {
"content-type": "application/json",
"x-real-ip": ip,
...headers,
},
body: JSON.stringify({
daoId: "ens",
panel: "Token distribution",
description: "The displayed supply is stale.",
email: "reporter@example.com",
url: "http://localhost:3000/ens/token-distribution",
...overrides,
}),
});

type ClientIPTestCase = {
name: string;
ip: string;
headers: Record<string, string>;
};

describe("POST /api/report", () => {
beforeEach(() => {
process.env = {
...originalEnvironment,
CLICKUP_API_TOKEN: "clickup-token",
CLICKUP_REPORT_LIST_ID: "901327958573",
};
global.fetch = jest.fn();
});

afterAll(() => {
process.env = originalEnvironment;
global.fetch = originalFetch;
});

it("creates a ClickUp task containing report context", async () => {
(global.fetch as jest.MockedFunction<typeof fetch>).mockResolvedValue(
new Response("{}", { status: 200 }),
);

const response = await POST(createRequest("203.0.113.1"));

expect(response.status).toBe(200);
expect(global.fetch).toHaveBeenCalledWith(
"https://api.clickup.com/api/v2/list/901327958573/task",
expect.objectContaining({
headers: {
Authorization: "clickup-token",
"content-type": "application/json",
},
body: expect.stringContaining("[Report] ENS — Token distribution"),
}),
);
expect(
String(
(global.fetch as jest.MockedFunction<typeof fetch>).mock.calls[0][1]
?.body,
),
).toContain("reporter@example.com");
});

it("rejects the fourth report from one trusted IP", async () => {
(global.fetch as jest.MockedFunction<typeof fetch>).mockResolvedValue(
new Response("{}", { status: 200 }),
);
const ip = "203.0.113.2";

await POST(createRequest(ip));
await POST(createRequest(ip));
await POST(createRequest(ip));
const response = await POST(createRequest(ip));

expect(response.status).toBe(429);
});

it("returns a graceful error when ClickUp fails", async () => {
(global.fetch as jest.MockedFunction<typeof fetch>).mockResolvedValue(
new Response("error", { status: 500 }),
);

const response = await POST(createRequest("203.0.113.3"));

expect(response.status).toBe(502);
await expect(response.json()).resolves.toEqual({
error: "We couldn't submit your report. Please try again shortly.",
});
});

it("includes subject in ClickUp title when provided", async () => {
(global.fetch as jest.MockedFunction<typeof fetch>).mockResolvedValue(
new Response("{}", { status: 200 }),
);

const response = await POST(
createRequest("203.0.113.4", { subject: "0xabc123" }),
);

expect(response.status).toBe(200);
expect(global.fetch).toHaveBeenCalledWith(
"https://api.clickup.com/api/v2/list/901327958573/task",
expect.objectContaining({
body: expect.stringContaining(
"[Report] ENS — Token distribution (0xabc123)",
),
}),
);
});

it.each<ClientIPTestCase>([
{
name: "Railway real IP",
ip: "203.0.113.5",
headers: { "x-real-ip": "203.0.113.5" },
},
{
name: "Vercel forwarded IP",
ip: "",
headers: { "x-forwarded-for": "203.0.113.6, 10.0.0.2" },
},
{
name: "valid forwarded IP after malformed real IP",
ip: "not-an-ip",
headers: {
"x-real-ip": "not-an-ip",
"x-forwarded-for": "203.0.113.7",
},
},
])("rate-limits by $name", async ({ ip, headers }) => {
(global.fetch as jest.MockedFunction<typeof fetch>).mockResolvedValue(
new Response("{}", { status: 200 }),
);

await POST(createRequest(ip, {}, headers));
await POST(createRequest(ip, {}, headers));
await POST(createRequest(ip, {}, headers));
const response = await POST(createRequest(ip, {}, headers));

expect(response.status).toBe(429);
});
});
Loading
Loading