Skip to content

Commit 21759c3

Browse files
committed
[Dashboard] Add audit log feature for Scale plan teams
1 parent 7fa5be5 commit 21759c3

File tree

10 files changed

+563
-8
lines changed

10 files changed

+563
-8
lines changed

apps/dashboard/src/@/api/audit-log.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"use server";
2+
3+
import "server-only";
4+
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
5+
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
6+
7+
export type AuditLogEntry = {
8+
who: {
9+
text: string;
10+
metadata?: {
11+
email?: string;
12+
image?: string;
13+
wallet?: string;
14+
clientId?: string;
15+
};
16+
type: "user" | "apikey" | "system";
17+
path?: string;
18+
};
19+
what: {
20+
text: string;
21+
action: "create" | "update" | "delete";
22+
path?: string;
23+
in?: {
24+
text: string;
25+
path?: string;
26+
};
27+
description?: string;
28+
resourceType:
29+
| "team"
30+
| "project"
31+
| "team-member"
32+
| "team-invite"
33+
| "contract"
34+
| "secret-key";
35+
};
36+
when: string;
37+
};
38+
39+
type AuditLogApiResponse = {
40+
result: AuditLogEntry[];
41+
nextCursor?: string;
42+
hasMore: boolean;
43+
};
44+
45+
export async function getAuditLogs(teamSlug: string, cursor?: string) {
46+
const authToken = await getAuthToken();
47+
if (!authToken) {
48+
throw new Error("No auth token found");
49+
}
50+
const url = new URL(
51+
`/v1/teams/${teamSlug}/audit-log`,
52+
NEXT_PUBLIC_THIRDWEB_API_HOST,
53+
);
54+
if (cursor) {
55+
url.searchParams.set("cursor", cursor);
56+
}
57+
// artifically limit page size to 15 for now
58+
url.searchParams.set("take", "15");
59+
60+
const response = await fetch(url, {
61+
next: {
62+
// revalidate this query once per 10 seconds (does not need to be more granular than that)
63+
revalidate: 10,
64+
},
65+
headers: {
66+
Authorization: `Bearer ${authToken}`,
67+
},
68+
});
69+
if (!response.ok) {
70+
// if the status is 402, the most likely reason is that the team is on a free plan
71+
if (response.status === 402) {
72+
return {
73+
status: "error",
74+
reason: "higher_plan_required",
75+
} as const;
76+
}
77+
const body = await response.text();
78+
return {
79+
status: "error",
80+
reason: "unknown",
81+
body,
82+
} as const;
83+
}
84+
85+
const data = (await response.json()) as AuditLogApiResponse;
86+
87+
return {
88+
status: "success",
89+
data,
90+
} as const;
91+
}

apps/dashboard/src/@/api/universal-bridge/developer.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,7 @@ export async function createWebhook(props: {
4747
secret?: string;
4848
}) {
4949
const authToken = await getAuthToken();
50-
console.log(
51-
"UB_BASE_URL",
52-
UB_BASE_URL,
53-
props.clientId,
54-
props.teamId,
55-
authToken,
56-
);
50+
5751
const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, {
5852
method: "POST",
5953
body: JSON.stringify({
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"use client";
2+
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
Card,
6+
CardContent,
7+
CardDescription,
8+
CardHeader,
9+
CardTitle,
10+
} from "@/components/ui/card";
11+
import { cn } from "@/lib/utils";
12+
import { CrownIcon, LockIcon, SparklesIcon } from "lucide-react";
13+
import Link from "next/link";
14+
import type React from "react";
15+
import { TeamPlanBadge } from "../../../app/(app)/components/TeamPlanBadge";
16+
import type { Team } from "../../api/team";
17+
import { Badge } from "../ui/badge";
18+
19+
interface UpsellWrapperProps {
20+
teamSlug: string;
21+
children: React.ReactNode;
22+
isLocked?: boolean;
23+
requiredPlan: Team["billingPlan"];
24+
currentPlan?: Team["billingPlan"];
25+
featureName: string;
26+
featureDescription: string;
27+
benefits?: {
28+
description: string;
29+
status: "available" | "soon";
30+
}[];
31+
className?: string;
32+
}
33+
34+
export function UpsellWrapper({
35+
teamSlug,
36+
children,
37+
isLocked = true,
38+
requiredPlan,
39+
currentPlan = "free",
40+
featureName,
41+
featureDescription,
42+
benefits = [],
43+
className,
44+
}: UpsellWrapperProps) {
45+
if (!isLocked) {
46+
return <>{children}</>;
47+
}
48+
49+
return (
50+
<div className={cn("relative flex-1", className)}>
51+
{/* Background content - blurred and non-interactive */}
52+
<div className="absolute inset-0 overflow-hidden">
53+
<div className="pointer-events-none select-none opacity-60 blur-[1px]">
54+
{children}
55+
</div>
56+
</div>
57+
58+
{/* Overlay gradient */}
59+
<div className="absolute inset-0 bg-gradient-to-b from-muted/20 via-muted/30 to-background" />
60+
61+
{/* Upsell content */}
62+
<div className="relative z-10 flex items-center justify-center p-16">
63+
<Card className="w-full max-w-2xl border-2 shadow-2xl">
64+
<CardHeader className="space-y-4 text-center">
65+
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full border-2 bg-muted">
66+
<LockIcon className="h-8 w-8 text-muted-foreground" />
67+
</div>
68+
69+
<div className="space-y-2">
70+
<TeamPlanBadge
71+
plan="scale"
72+
teamSlug={teamSlug}
73+
postfix=" Feature"
74+
/>
75+
<CardTitle className="font-bold text-2xl text-foreground md:text-3xl">
76+
Unlock {featureName}
77+
</CardTitle>
78+
<CardDescription className="mx-auto max-w-md text-base text-muted-foreground">
79+
{featureDescription}
80+
</CardDescription>
81+
</div>
82+
</CardHeader>
83+
84+
<CardContent className="space-y-6">
85+
{benefits.length > 0 && (
86+
<div className="space-y-3">
87+
<h4 className="font-semibold text-muted-foreground text-sm uppercase tracking-wide">
88+
What you'll get:
89+
</h4>
90+
<div className="grid gap-2">
91+
{benefits.map((benefit) => (
92+
<div
93+
key={benefit.description}
94+
className="flex items-center gap-3"
95+
>
96+
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-accent">
97+
<SparklesIcon className="h-3 w-3 text-success-text" />
98+
</div>
99+
<span className="text-sm">{benefit.description}</span>
100+
{benefit.status === "soon" && (
101+
<Badge variant="secondary" className="text-xs">
102+
Coming Soon
103+
</Badge>
104+
)}
105+
</div>
106+
))}
107+
</div>
108+
</div>
109+
)}
110+
111+
<div className="flex flex-col gap-3 pt-4 sm:flex-row">
112+
<Button className="flex-1 py-3 font-semibold" size="lg" asChild>
113+
<Link
114+
href={`/team/${teamSlug}/~/settings/billing?showPlans=true&highlight=${requiredPlan}`}
115+
>
116+
<CrownIcon className="mr-2 h-4 w-4" />
117+
Upgrade to{" "}
118+
<span className="ml-1 capitalize">{requiredPlan}</span>
119+
</Link>
120+
</Button>
121+
<Button variant="outline" size="lg" className="md:flex-1" asChild>
122+
<Link
123+
href={`/team/${teamSlug}/~/settings/billing?showPlans=true`}
124+
>
125+
View All Plans
126+
</Link>
127+
</Button>
128+
</div>
129+
130+
<div className="pt-2 text-center">
131+
<p className="text-muted-foreground text-xs">
132+
You are currently on the{" "}
133+
<span className="font-medium capitalize">{currentPlan}</span>{" "}
134+
plan.
135+
</p>
136+
</div>
137+
</CardContent>
138+
</Card>
139+
</div>
140+
</div>
141+
);
142+
}

apps/dashboard/src/app/(app)/(dashboard)/(chain)/types/chain.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ export type ChainSupportedService =
88
| "nebula"
99
| "pay"
1010
| "rpc-edge"
11-
| "chainsaw"
1211
| "insight";
1312

1413
export type ChainService = {

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/layout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ export default async function TeamLayout(props: {
9494
path: `/team/${params.team_slug}/~/usage`,
9595
name: "Usage",
9696
},
97+
{
98+
path: `/team/${params.team_slug}/~/audit-log`,
99+
name: "Audit Log",
100+
},
97101
{
98102
path: `/team/${params.team_slug}/~/settings`,
99103
name: "Settings",
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"use client";
2+
3+
import type { AuditLogEntry } from "@/api/audit-log";
4+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
5+
import { formatDistanceToNow } from "date-fns";
6+
import { KeyIcon, SettingsIcon, UserIcon } from "lucide-react";
7+
import Link from "next/link";
8+
9+
interface AuditLogEntryProps {
10+
entry: AuditLogEntry;
11+
}
12+
13+
export function AuditLogEntryComponent({ entry }: AuditLogEntryProps) {
14+
return (
15+
<div className="group -mx-4 border-border/40 border-b p-4 transition-colors last:border-b-0 hover:bg-muted/30">
16+
<div className="flex items-start justify-between gap-4">
17+
<div className="flex min-w-0 flex-1 items-center gap-3">
18+
{/* Actor indicator */}
19+
<Avatar className="size-10">
20+
<AvatarImage src={entry.who.metadata?.image} />
21+
<AvatarFallback>{getInitials(entry.who.text)}</AvatarFallback>
22+
</Avatar>
23+
24+
{/* Content */}
25+
<div className="min-w-0 flex-1 space-y-1">
26+
{/* Main action line */}
27+
<div className="flex flex-wrap items-center gap-1">
28+
<span className="font-medium text-sm">{entry.who.text}</span>
29+
<span className="text-muted-foreground text-sm">
30+
{entry.what.action}d
31+
</span>
32+
{entry.what.path ? (
33+
<Link
34+
href={entry.what.path}
35+
className="font-medium text-sm hover:underline"
36+
>
37+
{entry.what.text}
38+
</Link>
39+
) : (
40+
<span className="text-muted-foreground text-sm">
41+
{entry.what.text}
42+
</span>
43+
)}
44+
{entry.what.in && (
45+
<>
46+
<span className="text-muted-foreground text-sm">in</span>
47+
{entry.what.in.path ? (
48+
<Link
49+
href={entry.what.in.path}
50+
className="font-medium text-sm hover:underline"
51+
>
52+
{entry.what.in.text}
53+
</Link>
54+
) : (
55+
<span className="font-medium text-sm">
56+
{entry.what.in.text}
57+
</span>
58+
)}
59+
</>
60+
)}
61+
</div>
62+
63+
{/* Description */}
64+
{entry.what.description && (
65+
<p className="text-muted-foreground text-sm leading-relaxed">
66+
{entry.what.description}
67+
</p>
68+
)}
69+
70+
{/* Metadata */}
71+
<div className="flex items-center gap-3 text-muted-foreground text-xs">
72+
<div className="flex items-center gap-1">
73+
{getTypeIcon(entry.who.type)}
74+
<span className="capitalize">{entry.who.type}</span>
75+
</div>
76+
{entry.who.metadata?.email && (
77+
<span>{entry.who.metadata.email}</span>
78+
)}
79+
{entry.who.metadata?.wallet && (
80+
<span className="font-mono">
81+
{entry.who.metadata.wallet.slice(0, 6)}...
82+
{entry.who.metadata.wallet.slice(-4)}
83+
</span>
84+
)}
85+
{entry.who.metadata?.clientId && (
86+
<span>Client: {entry.who.metadata.clientId}</span>
87+
)}
88+
</div>
89+
</div>
90+
</div>
91+
92+
{/* Timestamp and action badge */}
93+
<div className="flex items-center gap-3 text-right">
94+
<div className="flex flex-col items-end gap-1">
95+
<span className="rounded-md bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
96+
{entry.what.action}
97+
</span>
98+
<span className="whitespace-nowrap text-muted-foreground text-xs">
99+
{formatDistanceToNow(entry.when, { addSuffix: true })}
100+
</span>
101+
</div>
102+
</div>
103+
</div>
104+
</div>
105+
);
106+
}
107+
108+
function getTypeIcon(type: AuditLogEntry["who"]["type"]) {
109+
switch (type) {
110+
case "user":
111+
return <UserIcon className="h-3.5 w-3.5" />;
112+
case "apikey":
113+
return <KeyIcon className="h-3.5 w-3.5" />;
114+
case "system":
115+
return <SettingsIcon className="h-3.5 w-3.5" />;
116+
default:
117+
return <UserIcon className="h-3.5 w-3.5" />;
118+
}
119+
}
120+
121+
function getInitials(text: string) {
122+
return text
123+
.split(" ")
124+
.map((word) => word[0])
125+
.join("")
126+
.toUpperCase()
127+
.slice(0, 2);
128+
}

0 commit comments

Comments
 (0)