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
50 changes: 36 additions & 14 deletions apps/dashboard/app/activity/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { formatRelativeTime } from "@/lib/format-relative-time";
import { useActivityFeed } from "@/lib/hooks/useActivityFeed";
import { convertToCsv, downloadCsv, } from "@/lib/csv-export";
import {
type ActivityEventEntity,
type ActivityEventSeverity,
type ActivityEventSource,
type ActivityEventType,
Expand Down Expand Up @@ -83,6 +84,15 @@ const SEVERITY_FILTERS: { label: string; value: ActivityEventSeverity | "" }[] =
{ label: "Critical", value: "critical" },
];

const ENTITY_TYPE_FILTERS: { label: string; value: ActivityEventEntity["type"] | "" }[] = [
{ label: "All entity types", value: "" },
{ label: "Pass", value: "pass" },
{ label: "Guild", value: "guild" },
{ label: "Member", value: "member" },
{ label: "Verification", value: "verification" },
{ label: "Webhook", value: "webhook" },
];

const SORT_OPTIONS: { label: string; value: ActivitySortOrder }[] = [
{ label: "Newest first", value: "newest" },
{ label: "Oldest first", value: "oldest" },
Expand All @@ -106,6 +116,7 @@ function ActivityPageContent() {
const [type, setType] = useState<ActivityEventType | "">(() => (searchParams.get("type") as ActivityEventType | null) ?? "");
const [source, setSource] = useState<ActivityEventSource | "">(() => (searchParams.get("source") as ActivityEventSource | null) ?? "");
const [severity, setSeverity] = useState<ActivityEventSeverity | "">(() => (searchParams.get("severity") as ActivityEventSeverity | null) ?? "");
const [entityType, setEntityType] = useState<ActivityEventEntity["type"] | "">(() => (searchParams.get("entityType") as ActivityEventEntity["type"] | null) ?? "");
const [actor, setActor] = useState(() => searchParams.get("actor") ?? "");
const [from, setFrom] = useState(() => searchParams.get("from") ?? "");
const [sort, setSort] = useState<ActivitySortOrder>(() => readSort(searchParams.get("sort")));
Expand All @@ -116,6 +127,7 @@ function ActivityPageContent() {
type?: ActivityEventType | "";
source?: ActivityEventSource | "";
severity?: ActivityEventSeverity | "";
entityType?: ActivityEventEntity["type"] | "";
actor?: string;
from?: string;
sort?: ActivitySortOrder;
Expand All @@ -134,6 +146,7 @@ function ActivityPageContent() {
if (updates.type !== undefined) setOrDelete("type", updates.type);
if (updates.source !== undefined) setOrDelete("source", updates.source);
if (updates.severity !== undefined) setOrDelete("severity", updates.severity);
if (updates.entityType !== undefined) setOrDelete("entityType", updates.entityType);
if (updates.actor !== undefined) setOrDelete("actor", updates.actor);
if (updates.from !== undefined) setOrDelete("from", updates.from);
if (updates.sort !== undefined) {
Expand Down Expand Up @@ -161,6 +174,7 @@ function ActivityPageContent() {
const nextType = (searchParams.get("type") as ActivityEventType | null) ?? "";
const nextSource = (searchParams.get("source") as ActivityEventSource | null) ?? "";
const nextSeverity = (searchParams.get("severity") as ActivityEventSeverity | null) ?? "";
const nextEntityType = (searchParams.get("entityType") as ActivityEventEntity["type"] | null) ?? "";
const nextActor = searchParams.get("actor") ?? "";
const nextFrom = searchParams.get("from") ?? "";
const nextSort = readSort(searchParams.get("sort"));
Expand All @@ -169,11 +183,12 @@ function ActivityPageContent() {
if (nextType !== type) setType(nextType);
if (nextSource !== source) setSource(nextSource);
if (nextSeverity !== severity) setSeverity(nextSeverity);
if (nextEntityType !== entityType) setEntityType(nextEntityType);
if (nextActor !== actor) setActor(nextActor);
if (nextFrom !== from) setFrom(nextFrom);
if (nextSort !== sort) setSort(nextSort);
if (nextLimit !== limit) setLimit(nextLimit);
}, [actor, from, limit, searchParams, severity, sort, source, type]);
}, [actor, entityType, from, limit, searchParams, severity, sort, source, type]);

const fromIso = useMemo(() => {
if (!from) return undefined;
Expand All @@ -197,6 +212,7 @@ function ActivityPageContent() {
type: type || undefined,
source: source || undefined,
severity: severity || undefined,
entityType: entityType || undefined,
actor: actor.trim() || undefined,
from: fromIso,
sort,
Expand Down Expand Up @@ -250,17 +266,18 @@ function ActivityPageContent() {
downloadCsv(csv, `activity-log-${date}.csv`);
};

const hasActiveFilters = Boolean(type || source || severity || actor.trim() || from || sort !== "newest" || limit !== 10);
const hasActiveFilters = Boolean(type || source || severity || entityType || actor.trim() || from || sort !== "newest" || limit !== 10);

const clearFilters = () => {
setType("");
setSource("");
setSeverity("");
setEntityType("");
setActor("");
setFrom("");
setSort("newest");
setLimit(10);
updateActivityQuery({ type: "", source: "", severity: "", actor: "", from: "", sort: "newest", limit: 10 });
updateActivityQuery({ type: "", source: "", severity: "", entityType: "", actor: "", from: "", sort: "newest", limit: 10 });
};

return (
Expand All @@ -282,16 +299,6 @@ function ActivityPageContent() {
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
title="Fetch the latest activity events"
>
</button>

<button
type="button"
onClick={refresh}
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
title="Fetch the latest activity events"
>

<svg
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
fill="none"
Expand All @@ -311,7 +318,7 @@ function ActivityPageContent() {
</div>

<div className="mb-4 rounded-xl border border-slate-200 bg-white p-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-7">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-8">
<label className="text-xs font-medium text-slate-600">
Event type
<select
Expand Down Expand Up @@ -357,6 +364,21 @@ function ActivityPageContent() {
</select>
</label>

<label className="text-xs font-medium text-slate-600">
Entity type
<select
value={entityType}
onChange={(event) => { const value = event.target.value as ActivityEventEntity["type"] | ""; setEntityType(value); updateActivityQuery({ entityType: value }); }}
className="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-100"
>
{ENTITY_TYPE_FILTERS.map((option) => (
<option key={option.value || "all-entity-types"} value={option.value}>
{option.label}
</option>
))}
</select>
</label>

<label className="text-xs font-medium text-slate-600">
Actor
<input
Expand Down
13 changes: 7 additions & 6 deletions apps/dashboard/lib/activity/mapper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ActivityEvent, WebhookPayload } from "./types";
import { CURRENT_ACTIVITY_EVENT_SCHEMA_VERSION } from "@guildpass/integration-client";
import { sanitiseWebhookData } from "./sanitise";

function displayValue(...values: Array<unknown>): string {
const value = values.find((item) => typeof item === "string" && item.length > 0);
Expand Down Expand Up @@ -34,7 +35,7 @@ export function mapWebhookToActivity(payload: WebhookPayload): ActivityEvent | n
id: entityId(data.id, data.wallet, data.name),
name: typeof data.name === "string" ? data.name : undefined,
},
metadata: data,
metadata: sanitiseWebhookData(type, data),
schemaVersion: CURRENT_ACTIVITY_EVENT_SCHEMA_VERSION,
};
}
Expand All @@ -58,7 +59,7 @@ export function mapWebhookToActivity(payload: WebhookPayload): ActivityEvent | n
id: entityId(data.id, data.wallet, data.name),
name: typeof data.name === "string" ? data.name : undefined,
},
metadata: data,
metadata: sanitiseWebhookData(type, data),
schemaVersion: CURRENT_ACTIVITY_EVENT_SCHEMA_VERSION,
};
}
Expand All @@ -81,7 +82,7 @@ export function mapWebhookToActivity(payload: WebhookPayload): ActivityEvent | n
id: entityId(data.id, data.name),
name: typeof data.name === "string" ? data.name : undefined,
},
metadata: data,
metadata: sanitiseWebhookData(type, data),
schemaVersion: CURRENT_ACTIVITY_EVENT_SCHEMA_VERSION,
};
}
Expand All @@ -104,7 +105,7 @@ export function mapWebhookToActivity(payload: WebhookPayload): ActivityEvent | n
id: entityId(data.id, data.name),
name: typeof data.name === "string" ? data.name : undefined,
},
metadata: data,
metadata: sanitiseWebhookData(type, data),
schemaVersion: CURRENT_ACTIVITY_EVENT_SCHEMA_VERSION,
};
}
Expand All @@ -127,7 +128,7 @@ export function mapWebhookToActivity(payload: WebhookPayload): ActivityEvent | n
id: entityId(data.id, data.name),
name: typeof data.name === "string" ? data.name : undefined,
},
metadata: data,
metadata: sanitiseWebhookData(type, data),
schemaVersion: CURRENT_ACTIVITY_EVENT_SCHEMA_VERSION,
};
}
Expand All @@ -147,7 +148,7 @@ export function mapWebhookToActivity(payload: WebhookPayload): ActivityEvent | n
type: "verification",
id: entityId(data.wallet),
},
metadata: data,
metadata: sanitiseWebhookData(type, data),
schemaVersion: CURRENT_ACTIVITY_EVENT_SCHEMA_VERSION,
};

Expand Down
59 changes: 54 additions & 5 deletions apps/dashboard/test/activity-mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe("mapWebhookToActivity", () => {
id: "member_456",
name: "Bob",
});
assert.strictEqual(activity?.metadata, data);
assert.deepStrictEqual(activity?.metadata, data);
});

test("maps pass.created webhooks", () => {
Expand All @@ -82,7 +82,7 @@ describe("mapWebhookToActivity", () => {
id: "pass_123",
name: "Founders Pass",
});
assert.strictEqual(activity?.metadata, data);
assert.deepStrictEqual(activity?.metadata, data);
});

test("maps pass.updated webhooks", () => {
Expand All @@ -100,7 +100,7 @@ describe("mapWebhookToActivity", () => {
id: "pass_456",
name: "VIP Pass",
});
assert.strictEqual(activity?.metadata, data);
assert.deepStrictEqual(activity?.metadata, data);
});

test("maps guild.updated webhooks", () => {
Expand All @@ -118,7 +118,7 @@ describe("mapWebhookToActivity", () => {
id: "guild_123",
name: "Adamantine Guild",
});
assert.strictEqual(activity?.metadata, data);
assert.deepStrictEqual(activity?.metadata, data);
});

test("maps verification.completed webhooks", () => {
Expand All @@ -135,7 +135,7 @@ describe("mapWebhookToActivity", () => {
type: "verification",
id: "0x987",
});
assert.strictEqual(activity?.metadata, data);
assert.deepStrictEqual(activity?.metadata, data);
});

test("returns null for unsupported webhook event types", () => {
Expand Down Expand Up @@ -174,3 +174,52 @@ describe("mapWebhookToActivity", () => {
assert.strictEqual(guild?.description, "Guild settings updated: guild_without_name");
});
});

describe("mapWebhookToActivity metadata sanitisation", () => {
test("strips fields outside the per-type allowlist from stored metadata", () => {
const activity = mapWebhookToActivity(
payload({
id: "evt_membership_created_extra_fields",
type: "membership.created",
data: {
id: "member_123",
name: "Alice",
wallet: "0xabc",
email: "alice@example.com",
password: "hunter2",
},
})
);

assert.deepStrictEqual(activity?.metadata, {
id: "member_123",
name: "Alice",
wallet: "0xabc",
});
assert.strictEqual("email" in (activity?.metadata ?? {}), false);
assert.strictEqual("password" in (activity?.metadata ?? {}), false);
});

test("stores an empty metadata object for unrecognised data fields", () => {
const activity = mapWebhookToActivity(
payload({
id: "evt_pass_created_extra_fields",
type: "pass.created",
data: { id: "pass_123", name: "Founders Pass", internalNotes: "do not expose" },
})
);

assert.deepStrictEqual(activity?.metadata, { id: "pass_123", name: "Founders Pass" });
assert.strictEqual("internalNotes" in (activity?.metadata ?? {}), false);
});

test("never stores the raw webhook data object as metadata by reference", () => {
const data = { id: "guild_123", name: "Adamantine Guild", secret: "leaked" };
const activity = mapWebhookToActivity(
payload({ id: "evt_guild_updated_extra_fields", type: "guild.updated", data })
);

assert.notStrictEqual(activity?.metadata, data);
assert.strictEqual("secret" in (activity?.metadata ?? {}), false);
});
});
24 changes: 24 additions & 0 deletions apps/dashboard/test/activity-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ describe("activity query contract", () => {
assert.equal(result.nextCursor, "evt_query_003");
});

test("filters by entityType alone", () => {
const result = filterActivityEvents(events, { entityType: "verification", limit: 10 });

assert.deepEqual(
result.events.map((event) => event.id),
["evt_query_003"]
);
assert.equal(result.nextCursor, null);
});

test("returns an empty page for valid filters with no matches", () => {
const result = filterActivityEvents(events, {
type: "guild.deleted",
Expand Down Expand Up @@ -140,4 +150,18 @@ describe("activity query contract", () => {
["limit", "type", "sort", "from"]
);
});

test("rejects an invalid entityType parameter", () => {
const parsed = parseActivityQuery(
new URL("https://example.test/api/activity?entityType=not-real").searchParams
);

assert.equal(parsed.ok, false);
if (parsed.ok) return;

assert.deepEqual(
parsed.errors.map((error) => error.field),
["entityType"]
);
});
});
Loading