Skip to content

[Dashboard] Add audit log feature for Scale plan teams #7300

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 7, 2025
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
91 changes: 91 additions & 0 deletions apps/dashboard/src/@/api/audit-log.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 1 addition & 7 deletions apps/dashboard/src/@/api/universal-bridge/developer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
142 changes: 142 additions & 0 deletions apps/dashboard/src/@/components/blocks/upsell-wrapper.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={cn("relative flex-1", className)}>
{/* Background content - blurred and non-interactive */}
<div className="absolute inset-0 overflow-hidden">
<div className="pointer-events-none select-none opacity-60 blur-[1px]">
{children}
</div>
</div>

{/* Overlay gradient */}
<div className="absolute inset-0 bg-gradient-to-b from-muted/20 via-muted/30 to-background" />

{/* Upsell content */}
<div className="relative z-10 flex items-center justify-center p-16">
<Card className="w-full max-w-2xl border-2 shadow-2xl">
<CardHeader className="space-y-4 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full border-2 bg-muted">
<LockIcon className="h-8 w-8 text-muted-foreground" />
</div>

<div className="space-y-2">
<TeamPlanBadge
plan="scale"
teamSlug={teamSlug}
postfix=" Feature"
/>
<CardTitle className="font-bold text-2xl text-foreground md:text-3xl">
Unlock {featureName}
</CardTitle>
<CardDescription className="mx-auto max-w-md text-base text-muted-foreground">
{featureDescription}
</CardDescription>
</div>
</CardHeader>

<CardContent className="space-y-6">
{benefits.length > 0 && (
<div className="space-y-3">
<h4 className="font-semibold text-muted-foreground text-sm uppercase tracking-wide">
What you'll get:
</h4>
<div className="grid gap-2">
{benefits.map((benefit) => (
<div
key={benefit.description}
className="flex items-center gap-3"
>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-accent">
<SparklesIcon className="h-3 w-3 text-success-text" />
</div>
<span className="text-sm">{benefit.description}</span>
{benefit.status === "soon" && (
<Badge variant="secondary" className="text-xs">
Coming Soon
</Badge>
)}
</div>
))}
</div>
</div>
)}

<div className="flex flex-col gap-3 pt-4 sm:flex-row">
<Button className="flex-1 py-3 font-semibold" size="lg" asChild>
<Link
href={`/team/${teamSlug}/~/settings/billing?showPlans=true&highlight=${requiredPlan}`}
>
<CrownIcon className="mr-2 h-4 w-4" />
Upgrade to{" "}
<span className="ml-1 capitalize">{requiredPlan}</span>
</Link>
</Button>
<Button variant="outline" size="lg" className="md:flex-1" asChild>
<Link
href={`/team/${teamSlug}/~/settings/billing?showPlans=true`}
>
View All Plans
</Link>
</Button>
</div>

<div className="pt-2 text-center">
<p className="text-muted-foreground text-xs">
You are currently on the{" "}
<span className="font-medium capitalize">{currentPlan}</span>{" "}
plan.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ export type ChainSupportedService =
| "nebula"
| "pay"
| "rpc-edge"
| "chainsaw"
| "insight";

export type ChainService = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="group -mx-4 border-border/40 border-b p-4 transition-colors last:border-b-0 hover:bg-muted/30">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 flex-1 items-center gap-3">
{/* Actor indicator */}
<Avatar className="size-10">
<AvatarImage src={entry.who.metadata?.image} />
<AvatarFallback>{getInitials(entry.who.text)}</AvatarFallback>
</Avatar>

{/* Content */}
<div className="min-w-0 flex-1 space-y-1">
{/* Main action line */}
<div className="flex flex-wrap items-center gap-1">
<span className="font-medium text-sm">{entry.who.text}</span>
<span className="text-muted-foreground text-sm">
{entry.what.action}d
</span>
{entry.what.path ? (
<Link
href={entry.what.path}
className="font-medium text-sm hover:underline"
>
{entry.what.text}
</Link>
) : (
<span className="text-muted-foreground text-sm">
{entry.what.text}
</span>
)}
{entry.what.in && (
<>
<span className="text-muted-foreground text-sm">in</span>
{entry.what.in.path ? (
<Link
href={entry.what.in.path}
className="font-medium text-sm hover:underline"
>
{entry.what.in.text}
</Link>
) : (
<span className="font-medium text-sm">
{entry.what.in.text}
</span>
)}
</>
)}
</div>

{/* Description */}
{entry.what.description && (
<p className="text-muted-foreground text-sm leading-relaxed">
{entry.what.description}
</p>
)}

{/* Metadata */}
<div className="flex items-center gap-3 text-muted-foreground text-xs">
<div className="flex items-center gap-1">
{getTypeIcon(entry.who.type)}
<span className="capitalize">{entry.who.type}</span>
</div>
{entry.who.metadata?.email && (
<span>{entry.who.metadata.email}</span>
)}
{entry.who.metadata?.wallet && (
<span className="font-mono">
{entry.who.metadata.wallet.slice(0, 6)}...
{entry.who.metadata.wallet.slice(-4)}
</span>
)}
{entry.who.metadata?.clientId && (
<span>Client: {entry.who.metadata.clientId}</span>
)}
</div>
</div>
</div>

{/* Timestamp and action badge */}
<div className="flex items-center gap-3 text-right">
<div className="flex flex-col items-end gap-1">
<span className="rounded-md bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
{entry.what.action}
</span>
<span className="whitespace-nowrap text-muted-foreground text-xs">
{formatDistanceToNow(entry.when, { addSuffix: true })}
</span>
</div>
</div>
</div>
</div>
);
}

function getTypeIcon(type: AuditLogEntry["who"]["type"]) {
switch (type) {
case "user":
return <UserIcon className="h-3.5 w-3.5" />;
case "apikey":
return <KeyIcon className="h-3.5 w-3.5" />;
case "system":
return <SettingsIcon className="h-3.5 w-3.5" />;
default:
return <UserIcon className="h-3.5 w-3.5" />;
}
}

function getInitials(text: string) {
return text
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
Loading
Loading