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 */}
+
+
+ {/* 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 (
+
+
+
+ );
+}
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);