From 21759c3c96e1642bafec7771f7c0c0f48a622acb Mon Sep 17 00:00:00 2001 From: Jonas Daniels Date: Sat, 7 Jun 2025 00:02:55 -0700 Subject: [PATCH] [Dashboard] Add audit log feature for Scale plan teams --- apps/dashboard/src/@/api/audit-log.ts | 91 +++++++++++ .../src/@/api/universal-bridge/developer.ts | 8 +- .../@/components/blocks/upsell-wrapper.tsx | 142 ++++++++++++++++++ .../(app)/(dashboard)/(chain)/types/chain.ts | 1 - .../(app)/team/[team_slug]/(team)/layout.tsx | 4 + .../(team)/~/audit-log/_components/entry.tsx | 128 ++++++++++++++++ .../(team)/~/audit-log/_components/list.tsx | 89 +++++++++++ .../[team_slug]/(team)/~/audit-log/layout.tsx | 37 +++++ .../[team_slug]/(team)/~/audit-log/page.tsx | 64 ++++++++ .../(team)/~/audit-log/search-params.ts | 7 + 10 files changed, 563 insertions(+), 8 deletions(-) create mode 100644 apps/dashboard/src/@/api/audit-log.ts create mode 100644 apps/dashboard/src/@/components/blocks/upsell-wrapper.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/_components/entry.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/_components/list.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/layout.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/page.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/search-params.ts diff --git a/apps/dashboard/src/@/api/audit-log.ts b/apps/dashboard/src/@/api/audit-log.ts new file mode 100644 index 00000000000..4ba20c14fc8 --- /dev/null +++ b/apps/dashboard/src/@/api/audit-log.ts @@ -0,0 +1,91 @@ +"use server"; + +import "server-only"; +import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs"; + +export type AuditLogEntry = { + who: { + text: string; + metadata?: { + email?: string; + image?: string; + wallet?: string; + clientId?: string; + }; + type: "user" | "apikey" | "system"; + path?: string; + }; + what: { + text: string; + action: "create" | "update" | "delete"; + path?: string; + in?: { + text: string; + path?: string; + }; + description?: string; + resourceType: + | "team" + | "project" + | "team-member" + | "team-invite" + | "contract" + | "secret-key"; + }; + when: string; +}; + +type AuditLogApiResponse = { + result: AuditLogEntry[]; + nextCursor?: string; + hasMore: boolean; +}; + +export async function getAuditLogs(teamSlug: string, cursor?: string) { + const authToken = await getAuthToken(); + if (!authToken) { + throw new Error("No auth token found"); + } + const url = new URL( + `/v1/teams/${teamSlug}/audit-log`, + NEXT_PUBLIC_THIRDWEB_API_HOST, + ); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + // artifically limit page size to 15 for now + url.searchParams.set("take", "15"); + + const response = await fetch(url, { + next: { + // revalidate this query once per 10 seconds (does not need to be more granular than that) + revalidate: 10, + }, + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + if (!response.ok) { + // if the status is 402, the most likely reason is that the team is on a free plan + if (response.status === 402) { + return { + status: "error", + reason: "higher_plan_required", + } as const; + } + const body = await response.text(); + return { + status: "error", + reason: "unknown", + body, + } as const; + } + + const data = (await response.json()) as AuditLogApiResponse; + + return { + status: "success", + data, + } as const; +} diff --git a/apps/dashboard/src/@/api/universal-bridge/developer.ts b/apps/dashboard/src/@/api/universal-bridge/developer.ts index caf67a085b0..e43ab63d861 100644 --- a/apps/dashboard/src/@/api/universal-bridge/developer.ts +++ b/apps/dashboard/src/@/api/universal-bridge/developer.ts @@ -47,13 +47,7 @@ export async function createWebhook(props: { secret?: string; }) { const authToken = await getAuthToken(); - console.log( - "UB_BASE_URL", - UB_BASE_URL, - props.clientId, - props.teamId, - authToken, - ); + const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, { method: "POST", body: JSON.stringify({ diff --git a/apps/dashboard/src/@/components/blocks/upsell-wrapper.tsx b/apps/dashboard/src/@/components/blocks/upsell-wrapper.tsx new file mode 100644 index 00000000000..a499d7161fe --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/upsell-wrapper.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { CrownIcon, LockIcon, SparklesIcon } from "lucide-react"; +import Link from "next/link"; +import type React from "react"; +import { TeamPlanBadge } from "../../../app/(app)/components/TeamPlanBadge"; +import type { Team } from "../../api/team"; +import { Badge } from "../ui/badge"; + +interface UpsellWrapperProps { + teamSlug: string; + children: React.ReactNode; + isLocked?: boolean; + requiredPlan: Team["billingPlan"]; + currentPlan?: Team["billingPlan"]; + featureName: string; + featureDescription: string; + benefits?: { + description: string; + status: "available" | "soon"; + }[]; + className?: string; +} + +export function UpsellWrapper({ + teamSlug, + children, + isLocked = true, + requiredPlan, + currentPlan = "free", + featureName, + featureDescription, + benefits = [], + className, +}: UpsellWrapperProps) { + if (!isLocked) { + return <>{children}; + } + + return ( +
+ {/* Background content - blurred and non-interactive */} +
+
+ {children} +
+
+ + {/* Overlay gradient */} +
+ + {/* Upsell content */} +
+ + +
+ +
+ +
+ + + Unlock {featureName} + + + {featureDescription} + +
+
+ + + {benefits.length > 0 && ( +
+

+ What you'll get: +

+
+ {benefits.map((benefit) => ( +
+
+ +
+ {benefit.description} + {benefit.status === "soon" && ( + + Coming Soon + + )} +
+ ))} +
+
+ )} + +
+ + +
+ +
+

+ You are currently on the{" "} + {currentPlan}{" "} + plan. +

+
+
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/types/chain.ts b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/types/chain.ts index a4cae5b3c9d..c6d3c0bffbc 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/types/chain.ts +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/types/chain.ts @@ -8,7 +8,6 @@ export type ChainSupportedService = | "nebula" | "pay" | "rpc-edge" - | "chainsaw" | "insight"; export type ChainService = { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/layout.tsx index d30dcdd5391..5adc8561115 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/layout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/layout.tsx @@ -94,6 +94,10 @@ export default async function TeamLayout(props: { path: `/team/${params.team_slug}/~/usage`, name: "Usage", }, + { + path: `/team/${params.team_slug}/~/audit-log`, + name: "Audit Log", + }, { path: `/team/${params.team_slug}/~/settings`, name: "Settings", diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/_components/entry.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/_components/entry.tsx new file mode 100644 index 00000000000..02bac873901 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/_components/entry.tsx @@ -0,0 +1,128 @@ +"use client"; + +import type { AuditLogEntry } from "@/api/audit-log"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { formatDistanceToNow } from "date-fns"; +import { KeyIcon, SettingsIcon, UserIcon } from "lucide-react"; +import Link from "next/link"; + +interface AuditLogEntryProps { + entry: AuditLogEntry; +} + +export function AuditLogEntryComponent({ entry }: AuditLogEntryProps) { + return ( +
+
+
+ {/* Actor indicator */} + + + {getInitials(entry.who.text)} + + + {/* Content */} +
+ {/* Main action line */} +
+ {entry.who.text} + + {entry.what.action}d + + {entry.what.path ? ( + + {entry.what.text} + + ) : ( + + {entry.what.text} + + )} + {entry.what.in && ( + <> + in + {entry.what.in.path ? ( + + {entry.what.in.text} + + ) : ( + + {entry.what.in.text} + + )} + + )} +
+ + {/* Description */} + {entry.what.description && ( +

+ {entry.what.description} +

+ )} + + {/* Metadata */} +
+
+ {getTypeIcon(entry.who.type)} + {entry.who.type} +
+ {entry.who.metadata?.email && ( + {entry.who.metadata.email} + )} + {entry.who.metadata?.wallet && ( + + {entry.who.metadata.wallet.slice(0, 6)}... + {entry.who.metadata.wallet.slice(-4)} + + )} + {entry.who.metadata?.clientId && ( + Client: {entry.who.metadata.clientId} + )} +
+
+
+ + {/* Timestamp and action badge */} +
+
+ + {entry.what.action} + + + {formatDistanceToNow(entry.when, { addSuffix: true })} + +
+
+
+
+ ); +} + +function getTypeIcon(type: AuditLogEntry["who"]["type"]) { + switch (type) { + case "user": + return ; + case "apikey": + return ; + case "system": + return ; + default: + return ; + } +} + +function getInitials(text: string) { + return text + .split(" ") + .map((word) => word[0]) + .join("") + .toUpperCase() + .slice(0, 2); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/_components/list.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/_components/list.tsx new file mode 100644 index 00000000000..bbf5608e98b --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/_components/list.tsx @@ -0,0 +1,89 @@ +"use client"; + +import type { AuditLogEntry } from "@/api/audit-log"; +import { Button } from "@/components/ui/button"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { useQueryState } from "nuqs"; +import { useTransition } from "react"; +import { searchParams } from "../search-params"; +import { AuditLogEntryComponent } from "./entry"; + +interface AuditLogListProps { + entries: AuditLogEntry[]; + hasMore: boolean; + nextCursor?: string; +} + +export function AuditLogList({ + entries, + hasMore, + nextCursor, +}: AuditLogListProps) { + const [isPending, startTransition] = useTransition(); + + const [after, setAfter] = useQueryState( + "after", + searchParams.after.withOptions({ + startTransition, + history: "push", + shallow: false, + }), + ); + + const showPagination = hasMore || !!after; + + return ( +
+
+ {entries.map((log) => ( + + ))} +
+ + {showPagination && ( +
+ + + +
+ )} +
+ ); +} + +function buildAuditLogEntryKey(entry: AuditLogEntry) { + return `${entry.who.type}-${entry.what.action}-${entry.what.path ?? ""}-${entry.when}-${entry.who.text}-${entry.what.resourceType}`; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/layout.tsx new file mode 100644 index 00000000000..38570d4a6bf --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/layout.tsx @@ -0,0 +1,37 @@ +import { redirect } from "next/navigation"; +import { getTeamBySlug } from "../../../../../../../@/api/team"; +import { UpsellWrapper } from "../../../../../../../@/components/blocks/upsell-wrapper"; + +export default async function Layout(props: { + children: React.ReactNode; + params: Promise<{ + team_slug: string; + }>; +}) { + const params = await props.params; + const team = await getTeamBySlug(params.team_slug); + if (!team) { + redirect("/team"); + } + return ( + +
+
+
+

+ Audit Log +

+
+
+ {props.children} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/page.tsx new file mode 100644 index 00000000000..02e83b4954f --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/page.tsx @@ -0,0 +1,64 @@ +import { getAuditLogs } from "@/api/audit-log"; +import { getTeamBySlug } from "@/api/team"; +import { redirect } from "next/navigation"; +import { getValidAccount } from "../../../../../account/settings/getAccount"; +import { AuditLogList } from "./_components/list"; +import { searchParamLoader } from "./search-params"; + +export default async function Page(props: { + params: Promise<{ + team_slug: string; + }>; + searchParams: Promise>; +}) { + const [params, searchParams] = await Promise.all([ + props.params, + searchParamLoader(props.searchParams), + ]); + const [, team] = await Promise.all([ + getValidAccount(`/team/${params.team_slug}/~/audit-log`), + getTeamBySlug(params.team_slug), + ]); + + if (!team) { + redirect("/team"); + } + + const auditLogs = await getAuditLogs( + team.slug, + searchParams.after ?? undefined, + ); + + if (auditLogs.status === "error") { + switch (auditLogs.reason) { + case "higher_plan_required": + return ( +
+ You need to upgrade to a paid plan to view audit logs. +
+ ); + default: + return ( +
+ Something went wrong. Please try again later. +
+ ); + } + } + + return ( +
+ {auditLogs.data.result.length === 0 ? ( +
+

No audit events found

+
+ ) : ( + + )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/search-params.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/search-params.ts new file mode 100644 index 00000000000..42b29e32615 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/audit-log/search-params.ts @@ -0,0 +1,7 @@ +import { createLoader, parseAsString } from "nuqs/server"; + +export const searchParams = { + after: parseAsString, +}; + +export const searchParamLoader = createLoader(searchParams);