[TOOL-3337] Dashboard: Engine cloud - DO NOT MERGE (#6639)
Co-authored-by: Prithvish Baidya <[email protected]> Co-authored-by: Joaquim Verges <[email protected]>
This commit is contained in:
co-authored by
Prithvish Baidya
Joaquim Verges
parent
ec50f8cc3c
commit
c81fed3617
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@thirdweb-dev/vault-sdk": patch
|
||||
---
|
||||
|
||||
Introducing vault sdk
|
||||
+2
-1
@@ -30,4 +30,5 @@ packages/*/typedoc/*
|
||||
storybook-static
|
||||
.aider*
|
||||
|
||||
tsconfig.tsbuildinfo
|
||||
tsconfig.tsbuildinfo
|
||||
.cursor
|
||||
@@ -104,4 +104,8 @@ ANALYTICS_SERVICE_URL=""
|
||||
NEXT_PUBLIC_NEBULA_URL=""
|
||||
|
||||
# required for billing parts of the dashboard (team -> settings -> billing / invoices)
|
||||
STRIPE_SECRET_KEY=""
|
||||
STRIPE_SECRET_KEY=""
|
||||
|
||||
# required for server wallet management
|
||||
NEXT_PUBLIC_THIRDWEB_VAULT_URL=""
|
||||
NEXT_PUBLIC_ENGINE_CLOUD_URL=""
|
||||
@@ -12,6 +12,7 @@
|
||||
"ignoreDependencies": [
|
||||
"@storybook/blocks",
|
||||
"@thirdweb-dev/service-utils",
|
||||
"@thirdweb-dev/vault-sdk",
|
||||
"@types/color",
|
||||
"fast-xml-parser"
|
||||
]
|
||||
|
||||
@@ -45,11 +45,13 @@
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-switch": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "1.2.3",
|
||||
"@scalar/api-reference-react": "^0.6.19",
|
||||
"@sentry/nextjs": "9.13.0",
|
||||
"@shazow/whatsabi": "0.21.0",
|
||||
"@tanstack/react-query": "5.74.4",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@thirdweb-dev/vault-sdk": "workspace:*",
|
||||
"@vercel/functions": "2.0.0",
|
||||
"@vercel/og": "^0.6.8",
|
||||
"abitype": "1.0.8",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
import { API_SERVER_URL, THIRDWEB_ENGINE_CLOUD_URL } from "../constants/env";
|
||||
|
||||
type ProxyActionParams = {
|
||||
pathname: string;
|
||||
@@ -79,6 +79,10 @@ export async function apiServerProxy<T>(params: ProxyActionParams) {
|
||||
return proxy<T>(API_SERVER_URL, params);
|
||||
}
|
||||
|
||||
export async function engineCloudProxy<T>(params: ProxyActionParams) {
|
||||
return proxy<T>(THIRDWEB_ENGINE_CLOUD_URL, params);
|
||||
}
|
||||
|
||||
export async function payServerProxy<T>(params: ProxyActionParams) {
|
||||
return proxy<T>(
|
||||
process.env.NEXT_PUBLIC_PAY_URL
|
||||
|
||||
@@ -95,7 +95,7 @@ export function FullWidthSidebarLayout(props: {
|
||||
links={[...contentSidebarLinks, ...(footerSidebarLinks || [])]}
|
||||
/>
|
||||
|
||||
<main className="z-0 flex min-w-0 grow flex-col max-sm:w-full">
|
||||
<main className="flex min-w-0 grow flex-col max-sm:w-full">
|
||||
{children}
|
||||
</main>
|
||||
<AppFooter containerClassName="max-w-7xl" />
|
||||
|
||||
@@ -129,7 +129,9 @@ export const SelectWithSearch = React.forwardRef<
|
||||
selectedOption && "text-foreground",
|
||||
)}
|
||||
>
|
||||
{selectedOption?.label || placeholder}
|
||||
{renderOption && selectedOption
|
||||
? renderOption(selectedOption)
|
||||
: selectedOption?.label || placeholder}
|
||||
</span>
|
||||
<ChevronDownIcon className="size-4 cursor-pointer text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ const buttonVariants = cva(
|
||||
link: "text-primary underline-offset-4 hover:underline text-semibold",
|
||||
pink: "border border-nebula-pink-foreground !text-nebula-pink-foreground bg-[hsl(var(--nebula-pink-foreground)/5%)] hover:bg-nebula-pink-foreground/10 dark:!text-foreground dark:bg-nebula-pink-foreground/10 dark:hover:bg-nebula-pink-foreground/20",
|
||||
upsell:
|
||||
"bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200",
|
||||
"bg-green-600 text-white hover:bg-green-700 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Button } from "./button";
|
||||
import { ToolTipLabel } from "./tooltip";
|
||||
|
||||
export type TabLink = {
|
||||
name: string;
|
||||
name: React.ReactNode;
|
||||
href: string;
|
||||
isActive: boolean;
|
||||
isDisabled?: boolean;
|
||||
@@ -43,7 +43,7 @@ export function TabLinks(props: {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
key={tab.name}
|
||||
key={tab.href}
|
||||
disabled={tab.isDisabled}
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -206,7 +206,7 @@ function useUnderline<El extends HTMLElement>() {
|
||||
|
||||
export function TabPathLinks(props: {
|
||||
links: {
|
||||
name: string;
|
||||
name: React.ReactNode;
|
||||
path: string;
|
||||
exactMatch?: boolean;
|
||||
isDisabled?: boolean;
|
||||
|
||||
@@ -9,6 +9,12 @@ export const NEXT_PUBLIC_NEBULA_APP_CLIENT_ID =
|
||||
|
||||
export const NEBULA_APP_SECRET_KEY = process.env.NEBULA_APP_SECRET_KEY || "";
|
||||
|
||||
export const THIRDWEB_VAULT_URL =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_VAULT_URL || "";
|
||||
|
||||
export const THIRDWEB_ENGINE_CLOUD_URL =
|
||||
process.env.NEXT_PUBLIC_ENGINE_CLOUD_URL || "";
|
||||
|
||||
export const THIRDWEB_API_SECRET = process.env.API_SERVER_SECRET || "";
|
||||
|
||||
export const IPFS_GATEWAY_URL =
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { differenceInCalendarDays } from "date-fns";
|
||||
import {
|
||||
type DurationId,
|
||||
type Range,
|
||||
getLastNDaysRange,
|
||||
} from "../../components/analytics/date-range-selector";
|
||||
|
||||
export function normalizeTime(date: Date) {
|
||||
const newDate = new Date(date);
|
||||
newDate.setHours(1, 0, 0, 0);
|
||||
return newDate;
|
||||
}
|
||||
|
||||
export function normalizeTimeISOString(date: Date) {
|
||||
return normalizeTime(date).toISOString();
|
||||
}
|
||||
|
||||
export function getFiltersFromSearchParams(params: {
|
||||
from: string | undefined | string[];
|
||||
to: string | undefined | string[];
|
||||
interval: string | undefined | string[];
|
||||
defaultRange: DurationId;
|
||||
}) {
|
||||
const fromStr = params.from;
|
||||
const toStr = params.to;
|
||||
const defaultRange = getLastNDaysRange(params.defaultRange);
|
||||
|
||||
const range: Range =
|
||||
fromStr && toStr && typeof fromStr === "string" && typeof toStr === "string"
|
||||
? {
|
||||
from: normalizeTime(new Date(fromStr)),
|
||||
to: normalizeTime(new Date(toStr)),
|
||||
type: "custom",
|
||||
}
|
||||
: {
|
||||
from: normalizeTime(defaultRange.from),
|
||||
to: normalizeTime(defaultRange.to),
|
||||
type: defaultRange.type,
|
||||
};
|
||||
|
||||
const defaultInterval =
|
||||
differenceInCalendarDays(range.to, range.from) > 30
|
||||
? "week"
|
||||
: ("day" as const);
|
||||
|
||||
return {
|
||||
range,
|
||||
interval:
|
||||
params.interval === "day"
|
||||
? ("day" as const)
|
||||
: params.interval === "week"
|
||||
? ("week" as const)
|
||||
: defaultInterval,
|
||||
};
|
||||
}
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import type { ResultItem } from "app/(app)/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/metrics/components/StatusCodes";
|
||||
import type { EngineBackendWalletType } from "lib/engine";
|
||||
import { useState } from "react";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { EngineStatus } from "../../../app/(app)/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/transactions-table";
|
||||
import type { ResultItem } from "../../../app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(instance)/[engineId]/metrics/components/StatusCodes";
|
||||
import type { EngineStatus } from "../../../app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(instance)/[engineId]/overview/components/transactions-table";
|
||||
import { engineKeys } from "../cache-keys";
|
||||
|
||||
// Engine instances
|
||||
@@ -1641,7 +1641,11 @@ interface EngineResourceMetrics {
|
||||
};
|
||||
}
|
||||
|
||||
export function useEngineSystemMetrics(engineId: string, teamIdOrSlug: string) {
|
||||
export function useEngineSystemMetrics(
|
||||
engineId: string,
|
||||
teamIdOrSlug: string,
|
||||
projectSlug: string,
|
||||
) {
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
return useQuery({
|
||||
@@ -1649,7 +1653,7 @@ export function useEngineSystemMetrics(engineId: string, teamIdOrSlug: string) {
|
||||
queryFn: async () => {
|
||||
const res = await apiServerProxy({
|
||||
method: "GET",
|
||||
pathname: `/v1/teams/${teamIdOrSlug}/engine/${engineId}/metrics`,
|
||||
pathname: `/v1/teams/${teamIdOrSlug}/${projectSlug}/engine/dedicated/${engineId}/metrics`,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -74,10 +74,6 @@ export default async function TeamLayout(props: {
|
||||
path: `/team/${params.team_slug}/~/analytics`,
|
||||
name: "Analytics",
|
||||
},
|
||||
{
|
||||
path: `/team/${params.team_slug}/~/engine`,
|
||||
name: "Engines",
|
||||
},
|
||||
{
|
||||
path: `/team/${params.team_slug}/~/ecosystem`,
|
||||
name: "Ecosystems",
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { SidebarLink } from "@/components/blocks/Sidebar";
|
||||
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import { ImportEngineLink } from "./_components";
|
||||
|
||||
export default async function Layout(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
}>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const linkPrefix = `/team/${params.team_slug}/~/engine`;
|
||||
const sidebarLinks: SidebarLink[] = [
|
||||
{
|
||||
label: "Engine Instances",
|
||||
href: `${linkPrefix}`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
label: "Import Engine",
|
||||
href: `${linkPrefix}/import`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
{/* header */}
|
||||
<header className="border-border border-b py-10">
|
||||
<div className="container flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">Engines</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<ImportEngineLink
|
||||
label="Import Engine"
|
||||
engineLinkPrefix={linkPrefix}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* sidebar layout */}
|
||||
<SidebarLayout sidebarLinks={sidebarLinks}>
|
||||
{props.children}
|
||||
</SidebarLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { getTeamBySlug } from "@/api/team";
|
||||
import { getTeamSubscriptions } from "@/api/team-subscription";
|
||||
import { fetchRPCUsage } from "@/api/usage/rpc";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { normalizeTimeISOString } from "lib/time";
|
||||
import { normalizeTimeISOString } from "@/lib/time";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getValidAccount } from "../../../../../account/settings/getAccount";
|
||||
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
|
||||
|
||||
+1
-1
@@ -234,7 +234,7 @@ function ProductsSection(props: {
|
||||
title: "Engine",
|
||||
description:
|
||||
"Scale your application with a backend server to read, write, and deploy contracts at production-grade.",
|
||||
href: `/team/${props.teamSlug}/~/engine`,
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/engine`,
|
||||
icon: EngineIcon,
|
||||
trackingLabel: "engine",
|
||||
},
|
||||
|
||||
+16
-4
@@ -8,9 +8,11 @@ import {
|
||||
WalletIcon,
|
||||
} from "lucide-react";
|
||||
import { ContractIcon } from "../../../../(dashboard)/(chain)/components/server/icons/ContractIcon";
|
||||
import { EngineIcon } from "../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
|
||||
import { InsightIcon } from "../../../../(dashboard)/(chain)/components/server/icons/InsightIcon";
|
||||
import { PayIcon } from "../../../../(dashboard)/(chain)/components/server/icons/PayIcon";
|
||||
import { SmartAccountIcon } from "../../../../(dashboard)/(chain)/components/server/icons/SmartAccountIcon";
|
||||
import { Badge } from "../../../../../../@/components/ui/badge";
|
||||
import { NebulaIcon } from "../../../../../nebula-app/(app)/icons/NebulaIcon";
|
||||
|
||||
export function ProjectSidebarLayout(props: {
|
||||
@@ -60,10 +62,14 @@ export function ProjectSidebarLayout(props: {
|
||||
tracking: tracking("contracts"),
|
||||
},
|
||||
{
|
||||
href: `${layoutPath}/nebula`,
|
||||
label: "Nebula",
|
||||
icon: NebulaIcon,
|
||||
tracking: tracking("nebula"),
|
||||
href: `${layoutPath}/engine`,
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Engine <Badge>New</Badge>
|
||||
</span>
|
||||
),
|
||||
icon: EngineIcon,
|
||||
tracking: tracking("engine"),
|
||||
},
|
||||
{
|
||||
href: `${layoutPath}/insight`,
|
||||
@@ -71,6 +77,12 @@ export function ProjectSidebarLayout(props: {
|
||||
icon: InsightIcon,
|
||||
tracking: tracking("insight"),
|
||||
},
|
||||
{
|
||||
href: `${layoutPath}/nebula`,
|
||||
label: "Nebula",
|
||||
icon: NebulaIcon,
|
||||
tracking: tracking("nebula"),
|
||||
},
|
||||
]}
|
||||
footerSidebarLinks={[
|
||||
{
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ export default async function Page(props: {
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex grow flex-col">
|
||||
<AccountAbstractionSummary
|
||||
teamId={project.teamId}
|
||||
projectId={project.id}
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import type { Project } from "@/api/projects";
|
||||
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
|
||||
import type { Wallet } from "../server-wallets/wallet-table/types";
|
||||
import { TransactionAnalyticsFilter } from "./filter";
|
||||
import { TransactionsChartCard } from "./tx-chart/tx-chart";
|
||||
import { TransactionsTable } from "./tx-table/tx-table";
|
||||
|
||||
export function TransactionsAnalyticsPageContent(props: {
|
||||
searchParams: {
|
||||
from?: string | undefined | string[];
|
||||
to?: string | undefined | string[];
|
||||
interval?: string | undefined | string[];
|
||||
};
|
||||
project: Project;
|
||||
showAnalytics: boolean;
|
||||
wallets?: Wallet[];
|
||||
teamSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSearchParamsProvider value={props.searchParams}>
|
||||
<div className="flex grow flex-col gap-6">
|
||||
{props.showAnalytics && (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<TransactionAnalyticsFilter />
|
||||
</div>
|
||||
<TransactionsChartCard
|
||||
searchParams={props.searchParams}
|
||||
project={props.project}
|
||||
wallets={props.wallets ?? []}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<TransactionsTable
|
||||
project={props.project}
|
||||
wallets={props.wallets}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
</div>
|
||||
</ResponsiveSearchParamsProvider>
|
||||
);
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { normalizeTimeISOString } from "@/lib/time";
|
||||
import {
|
||||
useResponsiveSearchParams,
|
||||
useSetResponsiveSearchParams,
|
||||
} from "responsive-rsc";
|
||||
import { DateRangeSelector } from "../../../../../../../../components/analytics/date-range-selector";
|
||||
import { IntervalSelector } from "../../../../../../../../components/analytics/interval-selector";
|
||||
import { getTxAnalyticsFiltersFromSearchParams } from "../lib/utils";
|
||||
|
||||
export function TransactionAnalyticsFilter() {
|
||||
const responsiveSearchParams = useResponsiveSearchParams();
|
||||
const setResponsiveSearchParams = useSetResponsiveSearchParams();
|
||||
|
||||
const { range, interval } = getTxAnalyticsFiltersFromSearchParams({
|
||||
from: responsiveSearchParams.from,
|
||||
to: responsiveSearchParams.to,
|
||||
interval: responsiveSearchParams.interval,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="no-scrollbar flex items-center gap-3 max-sm:overflow-auto">
|
||||
<DateRangeSelector
|
||||
range={range}
|
||||
popoverAlign="end"
|
||||
setRange={(newRange) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
return {
|
||||
...v,
|
||||
from: normalizeTimeISOString(newRange.from),
|
||||
to: normalizeTimeISOString(newRange.to),
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<IntervalSelector
|
||||
intervalType={interval}
|
||||
setIntervalType={(newInterval) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
return {
|
||||
...v,
|
||||
interval: newInterval,
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
import type { Project } from "@/api/projects";
|
||||
import { type Step, StepsCard } from "components/dashboard/StepsCard";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "../../../../../../../../@/components/ui/button";
|
||||
import CreateServerWallet from "../server-wallets/components/create-server-wallet.client";
|
||||
import type { Wallet } from "../server-wallets/wallet-table/types";
|
||||
import CreateVaultAccountButton from "../vault/components/create-vault-account.client";
|
||||
import { SendTestTransaction } from "./send-test-tx.client";
|
||||
|
||||
interface Props {
|
||||
managementAccessToken: string | undefined;
|
||||
project: Project;
|
||||
wallets: Wallet[];
|
||||
hasTransactions: boolean;
|
||||
teamSlug: string;
|
||||
testTxWithWallet?: string | undefined;
|
||||
}
|
||||
|
||||
export const EngineChecklist: React.FC<Props> = (props) => {
|
||||
const [userAccessToken, setUserAccessToken] = useState<string | undefined>();
|
||||
|
||||
const finalSteps = useMemo(() => {
|
||||
const steps: Step[] = [];
|
||||
steps.push({
|
||||
title: "Create a Vault Admin Account",
|
||||
description:
|
||||
"Vault is thirdweb's key management system. It allows you to create secure server wallets and manage access tokens.",
|
||||
children: (
|
||||
<CreateVaultAccountStep
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
onUserAccessTokenCreated={(token) => setUserAccessToken(token)}
|
||||
/>
|
||||
),
|
||||
completed: !!props.managementAccessToken,
|
||||
showCompletedChildren: false,
|
||||
});
|
||||
steps.push({
|
||||
title: "Create a Server Wallet",
|
||||
description:
|
||||
"Server wallets are smart wallets, they don't require any gas funds to send transactions.",
|
||||
children: (
|
||||
<CreateServerWalletStep
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
managementAccessToken={props.managementAccessToken}
|
||||
/>
|
||||
),
|
||||
completed: props.wallets.length > 0,
|
||||
showIncompleteChildren: false,
|
||||
showCompletedChildren: false,
|
||||
});
|
||||
steps.push({
|
||||
title: "Send a Test Transaction",
|
||||
description:
|
||||
"Engine handles gas fees, and is designed for scale, speed and security. Send a test transaction to see it in action",
|
||||
children: (
|
||||
<SendTestTransaction
|
||||
wallets={props.wallets}
|
||||
project={props.project}
|
||||
userAccessToken={userAccessToken}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
),
|
||||
completed: props.hasTransactions,
|
||||
showIncompleteChildren: false,
|
||||
showCompletedChildren: false,
|
||||
});
|
||||
return steps;
|
||||
}, [
|
||||
props.managementAccessToken,
|
||||
props.project,
|
||||
props.wallets,
|
||||
props.hasTransactions,
|
||||
userAccessToken,
|
||||
props.teamSlug,
|
||||
]);
|
||||
|
||||
if (props.testTxWithWallet) {
|
||||
return (
|
||||
<SendTestTransaction
|
||||
wallets={props.wallets}
|
||||
project={props.project}
|
||||
userAccessToken={userAccessToken}
|
||||
walletId={props.testTxWithWallet}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (finalSteps.length === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StepsCard title="Setup Your Engine" steps={finalSteps} delay={1000} />
|
||||
);
|
||||
};
|
||||
|
||||
function CreateVaultAccountStep(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
onUserAccessTokenCreated: (userAccessToken: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4 flex flex-row gap-4">
|
||||
<CreateVaultAccountButton
|
||||
project={props.project}
|
||||
onUserAccessTokenCreated={props.onUserAccessTokenCreated}
|
||||
/>
|
||||
<Link href="https://portal.thirdweb.com/engine/vault" target="_blank">
|
||||
<Button variant="outline">Learn more about Vault</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateServerWalletStep(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4 flex flex-row gap-4">
|
||||
<CreateServerWallet
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
managementAccessToken={props.managementAccessToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
"use client";
|
||||
import { engineCloudProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/projects";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2Icon, LockIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
arbitrumSepolia,
|
||||
baseSepolia,
|
||||
optimismSepolia,
|
||||
sepolia,
|
||||
} from "thirdweb/chains";
|
||||
import * as z from "zod";
|
||||
import { CopyTextButton } from "../../../../../../../../@/components/ui/CopyTextButton";
|
||||
import { useTrack } from "../../../../../../../../hooks/analytics/useTrack";
|
||||
import type { Wallet } from "../server-wallets/wallet-table/types";
|
||||
import { SmartAccountCell } from "../server-wallets/wallet-table/wallet-table-ui.client";
|
||||
|
||||
const formSchema = z.object({
|
||||
accessToken: z.string().min(1, "Access token is required"),
|
||||
walletIndex: z.string(),
|
||||
chainId: z.number(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function SendTestTransaction(props: {
|
||||
wallets?: Wallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
userAccessToken?: string;
|
||||
expanded?: boolean;
|
||||
walletId?: string;
|
||||
}) {
|
||||
const thirdwebClient = useThirdwebClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [hasSentTx, setHasSentTx] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
const trackEvent = useTrack();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
accessToken: props.userAccessToken ?? "",
|
||||
walletIndex:
|
||||
props.wallets && props.walletId
|
||||
? props.wallets
|
||||
.findIndex((w) => w.id === props.walletId)
|
||||
?.toString()
|
||||
.replace("-1", "0")
|
||||
: "0",
|
||||
chainId: 84532,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedWalletIndex = Number(form.watch("walletIndex"));
|
||||
const selectedWallet = props.wallets?.[selectedWalletIndex];
|
||||
|
||||
const sendDummyTxMutation = useMutation({
|
||||
mutationFn: async (args: {
|
||||
walletAddress: string;
|
||||
accessToken: string;
|
||||
chainId: number;
|
||||
}) => {
|
||||
trackEvent({
|
||||
category: "engine-cloud",
|
||||
action: "send_test_tx",
|
||||
});
|
||||
|
||||
const response = await engineCloudProxy({
|
||||
pathname: "/v1/write/transaction",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-team-id": props.project.teamId,
|
||||
"x-client-id": props.project.publishableKey,
|
||||
"x-vault-access-token": args.accessToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
executionOptions: {
|
||||
from: args.walletAddress,
|
||||
chainId: args.chainId.toString(),
|
||||
},
|
||||
params: [
|
||||
{
|
||||
to: args.walletAddress,
|
||||
value: "0",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMsg = response.error ?? "Failed to send transaction";
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Test transaction sent successfully!");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = sendDummyTxMutation.isPending;
|
||||
|
||||
// Early return in render phase
|
||||
if (!props.wallets || props.wallets.length === 0 || !selectedWallet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
await sendDummyTxMutation.mutateAsync({
|
||||
walletAddress: selectedWallet.address,
|
||||
accessToken: data.accessToken,
|
||||
chainId: data.chainId,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["transactions", props.project.id],
|
||||
});
|
||||
setHasSentTx(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 w-full rounded-md border bg-background p-6">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{props.walletId && (
|
||||
<h3 className="mb-4 font-medium text-lg">Send a test transaction</h3>
|
||||
)}
|
||||
<p className="flex items-center gap-2 text-sm text-warning-text">
|
||||
<LockIcon className="h-4 w-4" />
|
||||
{props.userAccessToken
|
||||
? "Copy your Vault access token, you'll need it for every HTTP call to Engine."
|
||||
: "Every wallet action requires your Vault access token."}
|
||||
</p>
|
||||
<div className="h-4" />
|
||||
{/* Responsive container */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-2">
|
||||
<div className="flex-grow">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm">Vault Access Token</p>
|
||||
{props.userAccessToken ? (
|
||||
<div className="flex flex-col gap-2 ">
|
||||
<CopyTextButton
|
||||
copyIconPosition="right"
|
||||
textToCopy={props.userAccessToken}
|
||||
textToShow={props.userAccessToken}
|
||||
tooltip="Copy Vault Access Token"
|
||||
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
This is a project-wide access token to access your server
|
||||
wallets. You can create more access tokens using your admin
|
||||
key, with granular scopes and permissions.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id="access-token"
|
||||
type={props.userAccessToken ? "text" : "password"}
|
||||
placeholder="vt_act_1234....ABCD"
|
||||
{...form.register("accessToken")}
|
||||
disabled={isLoading}
|
||||
className="text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-4" />
|
||||
{/* Wallet Selector */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-2">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<p className="text-sm">Server Wallet</p>
|
||||
<Select
|
||||
value={form.watch("walletIndex")}
|
||||
onValueChange={(value) => form.setValue("walletIndex", value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<SmartAccountCell wallet={selectedWallet} />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{selectedWallet.metadata.label}
|
||||
</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.wallets.map((wallet, index) => (
|
||||
<SelectItem key={wallet.address} value={index.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<SmartAccountCell wallet={wallet} />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{wallet.metadata.label}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<p className="text-sm">Network</p>
|
||||
<SingleNetworkSelector
|
||||
className="bg-background"
|
||||
chainIds={[
|
||||
baseSepolia.id,
|
||||
optimismSepolia.id,
|
||||
arbitrumSepolia.id,
|
||||
sepolia.id,
|
||||
1301,
|
||||
1946,
|
||||
1993,
|
||||
919,
|
||||
6342,
|
||||
10143,
|
||||
44787,
|
||||
80002,
|
||||
80069,
|
||||
98985,
|
||||
128123,
|
||||
167009,
|
||||
168587773,
|
||||
37714555429,
|
||||
1952959480,
|
||||
97,
|
||||
296,
|
||||
1740,
|
||||
4202,
|
||||
10200,
|
||||
]}
|
||||
client={thirdwebClient}
|
||||
chainId={form.watch("chainId")}
|
||||
onChange={(chainId) => {
|
||||
form.setValue("chainId", chainId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6" />
|
||||
<div className="flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant={hasSentTx ? "secondary" : "primary"}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
className="w-full min-w-[200px] md:w-auto"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>{hasSentTx ? "Send More Transactions" : "Send Transaction"}</>
|
||||
)}
|
||||
</Button>
|
||||
{hasSentTx && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => {
|
||||
if (props.walletId) {
|
||||
router.replace(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud`,
|
||||
);
|
||||
} else {
|
||||
router.refresh();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.walletId ? "Close" : "Complete Setup"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { StatCard } from "components/analytics/stat"; // Assuming correct path
|
||||
import { ActivityIcon, CoinsIcon } from "lucide-react";
|
||||
import { toEther } from "thirdweb/utils";
|
||||
import type { TransactionSummaryData } from "../lib/analytics";
|
||||
|
||||
// Renders the UI based on fetched data or pending state
|
||||
function TransactionAnalyticsSummaryUI(props: {
|
||||
data: TransactionSummaryData | undefined;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
// Formatter function specifically for the StatCard prop
|
||||
// Typed to accept number for StatCard's prop type, but receives the string via `as any`
|
||||
const parseTotalGasCost = (valueFromCard: string): number => {
|
||||
// At runtime, valueFromCard is the string passed via `as any` if data exists,
|
||||
// or potentially the fallback number (like 0) if data doesn't exist.
|
||||
// We prioritize the actual string from props.data if available.
|
||||
const weiString = props.data?.totalGasCostWei ?? "0";
|
||||
|
||||
// Check if the effective value is zero
|
||||
if (weiString === "0") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert the definitive wei string to BigInt
|
||||
const weiBigInt = BigInt(weiString);
|
||||
// Use the imported toEther function
|
||||
return Number.parseFloat(toEther(weiBigInt));
|
||||
} catch (e) {
|
||||
// Catch potential errors during BigInt conversion or formatting
|
||||
console.error("Error formatting wei value:", weiString, e);
|
||||
// Check if the value passed from card was actually the fallback number
|
||||
if (typeof valueFromCard === "number" && valueFromCard === 0) {
|
||||
return 0; // If fallback 0 was passed, display 0
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// NOTE: props.data?.totalGasUnitsUsed is fetched but not currently displayed.
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<StatCard
|
||||
label="Total Transactions"
|
||||
// Value is a number, standard formatter works
|
||||
value={props.data?.totalCount}
|
||||
icon={ActivityIcon}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Gas Spent (includes testnet)"
|
||||
// If pending, value doesn't matter much.
|
||||
// If not pending, pass the wei string `as any` if data exists, otherwise pass 0.
|
||||
// Passing 0 ensures StatCard receives a number if data is missing post-loading.
|
||||
value={
|
||||
props.isPending
|
||||
? undefined
|
||||
: parseTotalGasCost(props.data?.totalGasCostWei ?? "0")
|
||||
}
|
||||
formatter={(v: number) => `${v.toFixed(10)} ETH`}
|
||||
icon={CoinsIcon}
|
||||
// Pass the formatter that handles the type juggling
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
{/*
|
||||
// Example of how totalGasUnitsUsed could be added later:
|
||||
<StatCard
|
||||
label="Total Gas Units Used"
|
||||
value={(props.isPending ? undefined : (props.data?.totalGasUnitsUsed ?? 0)) as any} // Pass string as any
|
||||
icon={SomeOtherIcon}
|
||||
isPending={props.isPending}
|
||||
formatter={(value: number) => { // Formatter receives string via `as any`
|
||||
const unitString = props.data?.totalGasUnitsUsed ?? '0';
|
||||
if (unitString === '0') return '0';
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US").format(BigInt(unitString));
|
||||
} catch {
|
||||
return "Error";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
*/}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransactionAnalyticsSummary(props: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
initialData: TransactionSummaryData | undefined;
|
||||
}) {
|
||||
return (
|
||||
<TransactionAnalyticsSummaryUI data={props.initialData} isPending={false} />
|
||||
);
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import type { Project } from "@/api/projects";
|
||||
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { formatDate } from "date-fns";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { formatTickerNumber } from "lib/format-utils";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import type { TransactionStats } from "types/analytics";
|
||||
import type { Wallet } from "../../server-wallets/wallet-table/types";
|
||||
|
||||
type ChartData = Record<string, number> & {
|
||||
time: string;
|
||||
};
|
||||
|
||||
// TODO: write a story for this component when its finalized
|
||||
export function TransactionsChartCardUI(props: {
|
||||
userOpStats: TransactionStats[];
|
||||
isPending: boolean;
|
||||
project: Project;
|
||||
wallets: Wallet[];
|
||||
teamSlug: string;
|
||||
}) {
|
||||
const { userOpStats } = props;
|
||||
const topChainsToShow = 10;
|
||||
const chainsStore = useAllChainsData();
|
||||
|
||||
// TODO - update this if we need to change it
|
||||
const { chartConfig, chartData } = useMemo(() => {
|
||||
const _chartConfig: ChartConfig = {};
|
||||
const _chartDataMap: Map<string, ChartData> = new Map();
|
||||
const chainIdToVolumeMap: Map<string, number> = new Map();
|
||||
// for each stat, add it in _chartDataMap
|
||||
for (const stat of userOpStats) {
|
||||
const chartData = _chartDataMap.get(stat.date);
|
||||
const { chainId } = stat;
|
||||
const chain = chainsStore.idToChain.get(Number(chainId));
|
||||
|
||||
const chainName = chain?.name || chainId.toString() || "Unknown";
|
||||
// if no data for current day - create new entry
|
||||
if (!chartData) {
|
||||
_chartDataMap.set(stat.date, {
|
||||
time: stat.date,
|
||||
[chainName]: stat.count,
|
||||
} as ChartData);
|
||||
} else {
|
||||
chartData[chainName] = (chartData[chainName] || 0) + stat.count;
|
||||
}
|
||||
|
||||
chainIdToVolumeMap.set(
|
||||
chainName,
|
||||
stat.count + (chainIdToVolumeMap.get(chainName) || 0),
|
||||
);
|
||||
}
|
||||
|
||||
const chainsSorted = Array.from(chainIdToVolumeMap.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map((w) => w[0]);
|
||||
|
||||
const chainsToShow = chainsSorted.slice(0, topChainsToShow);
|
||||
const chainsToTagAsOthers = chainsSorted.slice(topChainsToShow);
|
||||
|
||||
// replace chainIdsToTagAsOther chainId with "other"
|
||||
for (const data of _chartDataMap.values()) {
|
||||
for (const chainId in data) {
|
||||
if (chainsToTagAsOthers.includes(chainId)) {
|
||||
data.others = (data.others || 0) + (data[chainId] || 0);
|
||||
delete data[chainId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chainsToShow.forEach((chainName, i) => {
|
||||
_chartConfig[chainName] = {
|
||||
label: chainName,
|
||||
color: `hsl(var(--chart-${(i % 10) + 1}))`,
|
||||
};
|
||||
});
|
||||
|
||||
// Add Other
|
||||
chainsToShow.push("others");
|
||||
_chartConfig.others = {
|
||||
label: "Others",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
};
|
||||
|
||||
return {
|
||||
chartData: Array.from(_chartDataMap.values()),
|
||||
chartConfig: _chartConfig,
|
||||
};
|
||||
}, [userOpStats, chainsStore]);
|
||||
|
||||
const uniqueChainIds = Object.keys(chartConfig);
|
||||
const disableActions =
|
||||
props.isPending ||
|
||||
chartData.length === 0 ||
|
||||
chartData.every((data) => data.transactions === 0);
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
customHeader={
|
||||
<div className="relative px-6 pt-6">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
Daily Transactions
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Amount of daily transactions by chain.
|
||||
</p>
|
||||
|
||||
<div className="top-6 right-6 mb-8 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
|
||||
<ExportToCSVButton
|
||||
className="bg-background"
|
||||
fileName="Sponsored Transactions"
|
||||
disabled={disableActions}
|
||||
getData={async () => {
|
||||
const header = ["Date", ...uniqueChainIds];
|
||||
const rows = chartData.map((data) => {
|
||||
const { time, ...rest } = data;
|
||||
return [
|
||||
time,
|
||||
...uniqueChainIds.map((w) => (rest[w] || 0).toString()),
|
||||
];
|
||||
});
|
||||
return { header, rows };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
config={chartConfig}
|
||||
data={chartData}
|
||||
isPending={props.isPending}
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
|
||||
showLegend
|
||||
hideLabel={false}
|
||||
toolTipLabelFormatter={(_v, item) => {
|
||||
if (Array.isArray(item)) {
|
||||
const time = item[0].payload.time as number;
|
||||
return formatDate(new Date(time), "MMM d, yyyy");
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
toolTipValueFormatter={(value) => formatTickerNumber(Number(value))}
|
||||
emptyChartState={
|
||||
<EmptyChartContent
|
||||
teamSlug={props.teamSlug}
|
||||
project={props.project}
|
||||
wallets={props.wallets}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO - update the title and doc links
|
||||
function EmptyChartContent(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
wallets: Wallet[];
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-4">
|
||||
{props.wallets.length === 0 ? (
|
||||
<>
|
||||
<span className="mb-6 text-center text-lg">
|
||||
Engine requires a{" "}
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/vault"
|
||||
target="_blank"
|
||||
className="underline"
|
||||
>
|
||||
Vault admin account
|
||||
</Link>
|
||||
. Create one to get started.
|
||||
</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud/vault`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Create Vault Admin Account
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="flex items-center gap-2 rounded-full border bg-background px-3.5 py-1.5 text-sm">
|
||||
<span className="!pointer-events-auto relative flex size-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
Waiting for transactions...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import type { Project } from "@/api/projects";
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getTransactionsChart } from "../../lib/analytics";
|
||||
import { getTxAnalyticsFiltersFromSearchParams } from "../../lib/utils";
|
||||
import type { Wallet } from "../../server-wallets/wallet-table/types";
|
||||
import { TransactionsChartCardUI } from "./tx-chart-ui";
|
||||
|
||||
async function AsyncTransactionsChartCard(props: {
|
||||
from: string;
|
||||
to: string;
|
||||
interval: "day" | "week";
|
||||
project: Project;
|
||||
wallets: Wallet[];
|
||||
teamSlug: string;
|
||||
}) {
|
||||
const data = await getTransactionsChart({
|
||||
clientId: props.project.publishableKey,
|
||||
teamId: props.project.teamId,
|
||||
from: props.from,
|
||||
to: props.to,
|
||||
interval: props.interval,
|
||||
});
|
||||
|
||||
return (
|
||||
<TransactionsChartCardUI
|
||||
isPending={false}
|
||||
userOpStats={data}
|
||||
project={props.project}
|
||||
wallets={props.wallets}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransactionsChartCard(props: {
|
||||
searchParams: {
|
||||
from?: string | undefined | string[];
|
||||
to?: string | undefined | string[];
|
||||
interval?: string | undefined | string[];
|
||||
};
|
||||
project: Project;
|
||||
wallets: Wallet[];
|
||||
teamSlug: string;
|
||||
}) {
|
||||
const { range, interval } = getTxAnalyticsFiltersFromSearchParams(
|
||||
props.searchParams,
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
// TODO - change this if this component does not end up using these params
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
fallback={
|
||||
<TransactionsChartCardUI
|
||||
isPending={true}
|
||||
userOpStats={[]}
|
||||
project={props.project}
|
||||
wallets={[]}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AsyncTransactionsChartCard
|
||||
from={range.from.toISOString()}
|
||||
to={range.to.toISOString()}
|
||||
interval={interval}
|
||||
project={props.project}
|
||||
wallets={props.wallets}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
|
||||
import type { Project } from "@/api/projects";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { PaginationButtons } from "@/components/pagination-buttons";
|
||||
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { format } from "date-fns/format";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { ExternalLinkIcon, InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { ChainIconClient } from "../../../../../../../../../components/icons/ChainIcon";
|
||||
import type { Wallet } from "../../server-wallets/wallet-table/types";
|
||||
import type {
|
||||
Transaction,
|
||||
TransactionStatus,
|
||||
TransactionsResponse,
|
||||
} from "./types";
|
||||
|
||||
// TODO - add Status selector dropdown here
|
||||
export function TransactionsTableUI(props: {
|
||||
getData: (params: { page: number }) => Promise<TransactionsResponse>;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
wallets?: Wallet[];
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||
const [status, setStatus] = useState<TransactionStatus | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const pageSize = 10;
|
||||
const transactionsQuery = useQuery({
|
||||
queryKey: ["transactions", props.project.id, page],
|
||||
queryFn: () => props.getData({ page }),
|
||||
refetchInterval: autoUpdate ? 4_000 : false,
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: !!props.wallets && props.wallets.length > 0,
|
||||
});
|
||||
|
||||
const transactions = transactionsQuery.data?.transactions ?? [];
|
||||
|
||||
const totalCount = transactionsQuery.data?.pagination.totalCount ?? 0;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const showPagination = totalCount > pageSize;
|
||||
|
||||
const showSkeleton =
|
||||
(transactionsQuery.isPlaceholderData && transactionsQuery.isFetching) ||
|
||||
(transactionsQuery.isLoading && !transactionsQuery.isPlaceholderData);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="flex flex-col gap-4 rounded-lg rounded-b-none px-6 py-6 lg:flex-row lg:justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
Transaction History
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Transactions sent from server wallets
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-5 border-border border-t pt-4 lg:border-none lg:pt-0">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Label htmlFor="auto-update">Auto Update</Label>
|
||||
<Switch
|
||||
checked={autoUpdate}
|
||||
onCheckedChange={(v) => setAutoUpdate(!!v)}
|
||||
id="auto-update"
|
||||
/>
|
||||
</div>
|
||||
<StatusSelector
|
||||
status={status}
|
||||
setStatus={(v) => {
|
||||
setStatus(v);
|
||||
// reset page
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableContainer className="rounded-none border-x-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Queue ID</TableHead>
|
||||
<TableHead>Chain</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>From</TableHead>
|
||||
<TableHead>Tx Hash</TableHead>
|
||||
<TableHead>Queued</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{showSkeleton ? (
|
||||
<>
|
||||
{new Array(pageSize).fill(0).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
<SkeletonRow key={i} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{transactions.map((tx) => (
|
||||
<TableRow
|
||||
key={`${tx.id}${tx.chainId}`}
|
||||
className="cursor-pointer hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud/tx/${tx.id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{/* Queue ID */}
|
||||
<TableCell className="font-medium">
|
||||
<CopyAddressButton
|
||||
address={tx.id ? tx.id : ""}
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Chain Id */}
|
||||
<TableCell>
|
||||
<TxChainCell chainId={tx.chainId || undefined} />
|
||||
</TableCell>
|
||||
|
||||
{/* Status */}
|
||||
<TableCell>
|
||||
<TxStatusCell transaction={tx} />
|
||||
</TableCell>
|
||||
|
||||
{/* From Address */}
|
||||
<TableCell>
|
||||
{tx.from ? <WalletAddress address={tx.from} /> : "N/A"}
|
||||
</TableCell>
|
||||
|
||||
{/* Tx Hash */}
|
||||
<TableCell>
|
||||
<TxHashCell transaction={tx} />
|
||||
</TableCell>
|
||||
|
||||
{/* Queued At */}
|
||||
<TableCell>
|
||||
<TxQueuedAtCell transaction={tx} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!showSkeleton && transactions.length === 0 && (
|
||||
<div className="flex min-h-[200px] items-center justify-center">
|
||||
No transactions found
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{showPagination && (
|
||||
<div className="py-6">
|
||||
<PaginationButtons
|
||||
activePage={page}
|
||||
onPageClick={setPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const statusDetails = {
|
||||
QUEUED: {
|
||||
name: "Queued",
|
||||
type: "warning",
|
||||
},
|
||||
SUBMITTED: {
|
||||
name: "Submitted",
|
||||
type: "warning",
|
||||
},
|
||||
CONFIRMED: {
|
||||
name: "Confirmed",
|
||||
type: "success",
|
||||
},
|
||||
REVERTED: {
|
||||
name: "Reverted",
|
||||
type: "destructive",
|
||||
},
|
||||
FAILED: {
|
||||
name: "Failed",
|
||||
type: "destructive",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function StatusSelector(props: {
|
||||
status: TransactionStatus | undefined;
|
||||
setStatus: (value: TransactionStatus | undefined) => void;
|
||||
}) {
|
||||
const statuses = Object.keys(statusDetails) as TransactionStatus[];
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={props.status || "all"}
|
||||
onValueChange={(v) => {
|
||||
if (v === "all") {
|
||||
props.setStatus(undefined);
|
||||
} else {
|
||||
props.setStatus(v as TransactionStatus);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="min-w-[160px]">
|
||||
<SelectValue placeholder="All" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">All Transactions</SelectItem>
|
||||
{statuses.map((item) => {
|
||||
return (
|
||||
<SelectItem key={item} value={item}>
|
||||
{statusDetails[item].name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<TableRow className="h-[73px]">
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[152px]" />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[155px]" />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[58px] rounded-full" />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[145px]" />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[160px]" />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[108px]" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TxChainCell(props: { chainId: string | undefined }) {
|
||||
const { chainId } = props;
|
||||
const { idToChain } = useAllChainsData();
|
||||
const thirdwebClient = useThirdwebClient();
|
||||
if (!chainId) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const chain = idToChain.get(Number.parseInt(chainId));
|
||||
|
||||
if (!chain) {
|
||||
return `Chain ID: ${chainId}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ChainIconClient
|
||||
className="size-5"
|
||||
client={thirdwebClient}
|
||||
src={chain.icon?.url}
|
||||
/>
|
||||
<div className="max-w-[150px] truncate">
|
||||
{chain.name ?? `Chain ID: ${chainId}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TxStatusCell(props: { transaction: Transaction }) {
|
||||
const { transaction } = props;
|
||||
const { errorMessage } = transaction;
|
||||
const minedAt = transaction.confirmedAt;
|
||||
const status =
|
||||
(transaction.executionResult?.status as TransactionStatus) ?? null;
|
||||
|
||||
const onchainStatus =
|
||||
transaction.executionResult &&
|
||||
"onchainStatus" in transaction.executionResult
|
||||
? transaction.executionResult.onchainStatus
|
||||
: null;
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tooltip =
|
||||
onchainStatus !== "REVERTED"
|
||||
? errorMessage
|
||||
: status === "CONFIRMED" && minedAt
|
||||
? `Completed ${format(new Date(minedAt), "PP pp")}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ToolTipLabel hoverable label={tooltip}>
|
||||
<Badge variant={statusDetails[status].type} className="gap-2">
|
||||
{statusDetails[status].name}
|
||||
{errorMessage && <InfoIcon className="size-3" />}
|
||||
</Badge>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
function TxHashCell(props: { transaction: Transaction }) {
|
||||
const { idToChain } = useAllChainsData();
|
||||
const { chainId, transactionHash } = props.transaction;
|
||||
if (!transactionHash) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
|
||||
const explorer = chain?.explorers?.[0];
|
||||
|
||||
const shortHash = `${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`;
|
||||
if (!explorer) {
|
||||
return (
|
||||
<CopyTextButton
|
||||
textToCopy={transactionHash}
|
||||
copyIconPosition="left"
|
||||
textToShow={shortHash}
|
||||
variant="ghost"
|
||||
tooltip="Copy transaction hash"
|
||||
className="font-mono text-muted-foreground text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
asChild
|
||||
className="-translate-x-2 gap-2 font-mono"
|
||||
size="sm"
|
||||
>
|
||||
<Link
|
||||
href={`${explorer.url}/tx/${transactionHash}`}
|
||||
target="_blank"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{shortHash}{" "}
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function TxQueuedAtCell(props: { transaction: Transaction }) {
|
||||
const value = props.transaction.createdAt;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return (
|
||||
<ToolTipLabel label={format(date, "PP pp z")}>
|
||||
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { engineCloudProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/projects";
|
||||
import type { Wallet } from "../../server-wallets/wallet-table/types";
|
||||
import { TransactionsTableUI } from "./tx-table-ui";
|
||||
import type { TransactionsResponse } from "./types";
|
||||
|
||||
export function TransactionsTable(props: {
|
||||
project: Project;
|
||||
wallets?: Wallet[];
|
||||
teamSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<TransactionsTableUI
|
||||
getData={async ({ page }) => {
|
||||
return await getTransactions({
|
||||
project: props.project,
|
||||
page,
|
||||
});
|
||||
}}
|
||||
project={props.project}
|
||||
wallets={props.wallets}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function getTransactions({
|
||||
project,
|
||||
page,
|
||||
}: {
|
||||
project: Project;
|
||||
page: number;
|
||||
}) {
|
||||
const transactions = await engineCloudProxy<{ result: TransactionsResponse }>(
|
||||
{
|
||||
pathname: "/v1/transactions/search",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-team-id": project.teamId,
|
||||
"x-client-id": project.publishableKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
page,
|
||||
limit: 20,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!transactions.ok) {
|
||||
throw new Error(transactions.error);
|
||||
}
|
||||
|
||||
return transactions.data.result;
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import type { Address, Hex } from "thirdweb";
|
||||
|
||||
// Revert data type (referenced but not defined in the provided code)
|
||||
type RevertDataSerialized = {
|
||||
revertReason?: string;
|
||||
decodedError?: {
|
||||
name: string;
|
||||
signature: string;
|
||||
args: string[];
|
||||
};
|
||||
};
|
||||
|
||||
// Transaction parameters
|
||||
type TransactionParamsSerialized = {
|
||||
to: Address;
|
||||
data: Hex;
|
||||
value: string;
|
||||
};
|
||||
|
||||
// Execution parameters
|
||||
type ExecutionParams4337Serialized = {
|
||||
type: "AA";
|
||||
entrypointAddress: string;
|
||||
smartAccountAddress: string;
|
||||
signerAddress: string;
|
||||
};
|
||||
|
||||
type ExecutionParamsSerialized = ExecutionParams4337Serialized;
|
||||
|
||||
// Execution result
|
||||
type ExecutionResult4337Serialized =
|
||||
| {
|
||||
status: "QUEUED";
|
||||
}
|
||||
| {
|
||||
status: "SUBMITTED";
|
||||
monitoringStatus: "WILL_MONITOR" | "CANNOT_MONITOR";
|
||||
userOpHash: string;
|
||||
}
|
||||
| ({
|
||||
status: "CONFIRMED";
|
||||
userOpHash: Hex;
|
||||
transactionHash: Hex;
|
||||
actualGasCost: string;
|
||||
actualGasUsed: string;
|
||||
nonce: string;
|
||||
} & (
|
||||
| {
|
||||
onchainStatus: "SUCCESS";
|
||||
}
|
||||
| {
|
||||
onchainStatus: "REVERTED";
|
||||
revertData?: RevertDataSerialized;
|
||||
}
|
||||
));
|
||||
|
||||
type ExecutionResultSerialized = ExecutionResult4337Serialized;
|
||||
|
||||
// Main Transaction type from database
|
||||
export type Transaction = {
|
||||
id: string;
|
||||
batchIndex: number;
|
||||
chainId: string;
|
||||
from: Address | null;
|
||||
transactionParams: TransactionParamsSerialized[];
|
||||
transactionHash: Hex | null;
|
||||
confirmedAt: Date | null;
|
||||
confirmedAtBlockNumber: string | null;
|
||||
enrichedData: unknown[];
|
||||
executionParams: ExecutionParamsSerialized;
|
||||
executionResult: ExecutionResultSerialized | null;
|
||||
createdAt: Date;
|
||||
errorMessage: string | null;
|
||||
cancelledAt: Date | null;
|
||||
};
|
||||
|
||||
export type TransactionStatus =
|
||||
| "QUEUED"
|
||||
| "SUBMITTED"
|
||||
| "CONFIRMED"
|
||||
| "REVERTED"
|
||||
| "FAILED";
|
||||
|
||||
type Pagination = {
|
||||
totalCount: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type TransactionsResponse = {
|
||||
transactions: Transaction[];
|
||||
pagination: Pagination;
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
import { ApiReferenceReact } from "@scalar/api-reference-react";
|
||||
import "@scalar/api-reference-react/style.css";
|
||||
import { THIRDWEB_ENGINE_CLOUD_URL } from "@/constants/env";
|
||||
|
||||
export function Scalar() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-bold text-2xl tracking-tight">Full API Reference</h2>
|
||||
<ApiReferenceReact
|
||||
configuration={{
|
||||
url: `${THIRDWEB_ENGINE_CLOUD_URL}/openapi`,
|
||||
hideModels: true,
|
||||
servers: [
|
||||
{
|
||||
url: THIRDWEB_ENGINE_CLOUD_URL,
|
||||
description: "Engine Cloud",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { TryItOut } from "../server-wallets/components/try-it-out";
|
||||
import { Scalar } from "./components/scalar";
|
||||
|
||||
export default async function TransactionsExplorerPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<TryItOut />
|
||||
<Scalar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
import { THIRDWEB_ENGINE_CLOUD_URL } from "@/constants/env";
|
||||
import Link from "next/link";
|
||||
import { EngineIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { team_slug, project_slug } = await props.params;
|
||||
|
||||
return (
|
||||
<TransactionsLayout projectSlug={project_slug} teamSlug={team_slug}>
|
||||
{props.children}
|
||||
</TransactionsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function TransactionsLayout(props: {
|
||||
projectSlug: string;
|
||||
teamSlug: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const engineBaseSlug = `/team/${props.teamSlug}/${props.projectSlug}/engine`;
|
||||
const engineLayoutSlug = `${engineBaseSlug}/cloud`;
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
{/* top */}
|
||||
<div className="pt-4 lg:pt-6">
|
||||
{/* header */}
|
||||
<div className="container flex max-w-7xl flex-col gap-4">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="mb-0.5 flex items-center gap-2 font-semibold text-3xl tracking-tight">
|
||||
Engine{" "}
|
||||
<Badge
|
||||
variant="success"
|
||||
className="mt-0.5 flex items-center gap-2 text-sm"
|
||||
>
|
||||
<EngineIcon className="size-4" /> Cloud
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="mt-0.5 flex items-center gap-2 text-sm"
|
||||
>
|
||||
Beta
|
||||
</Badge>
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`${THIRDWEB_ENGINE_CLOUD_URL}/reference`} // TODO: change this
|
||||
target="_blank"
|
||||
className="max-w-full truncate py-1 text-muted-foreground"
|
||||
>
|
||||
{THIRDWEB_ENGINE_CLOUD_URL}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`${engineBaseSlug}/dedicated`}>
|
||||
<Button variant="outline">View dedicated engines</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
{/* Nav */}
|
||||
<TabPathLinks
|
||||
scrollableClassName="container max-w-7xl"
|
||||
links={[
|
||||
{
|
||||
name: "Transactions",
|
||||
path: `${engineLayoutSlug}`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
name: "API Explorer",
|
||||
path: `${engineLayoutSlug}/explorer`,
|
||||
},
|
||||
{
|
||||
name: "Server Wallets",
|
||||
path: `${engineLayoutSlug}/server-wallets`,
|
||||
},
|
||||
{
|
||||
name: "Vault",
|
||||
path: `${engineLayoutSlug}/vault`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-6" />
|
||||
<div className="container flex max-w-7xl grow flex-col gap-6">
|
||||
<div>{props.children}</div>
|
||||
</div>
|
||||
<div className="h-20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
import { THIRDWEB_ENGINE_CLOUD_URL } from "@/constants/env";
|
||||
import type { TransactionStats } from "../../../../../../../../types/analytics";
|
||||
import { getAuthToken } from "../../../../../../api/lib/getAuthToken";
|
||||
import type {
|
||||
Transaction,
|
||||
TransactionsResponse,
|
||||
} from "../analytics/tx-table/types";
|
||||
|
||||
// Define the structure of the data we expect back from our fetch function
|
||||
export type TransactionSummaryData = {
|
||||
totalCount: number;
|
||||
totalGasCostWei: string;
|
||||
totalGasUnitsUsed: string; // Keep fetched data structure
|
||||
};
|
||||
|
||||
// Define the structure of the API response
|
||||
type AnalyticsSummaryApiResponse = {
|
||||
result: {
|
||||
summary: {
|
||||
totalCount: number;
|
||||
totalGasCostWei: string;
|
||||
totalGasUnitsUsed: string;
|
||||
};
|
||||
metadata: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Fetches data from the /analytics-summary endpoint
|
||||
export async function getTransactionAnalyticsSummary(props: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
}): Promise<TransactionSummaryData> {
|
||||
const authToken = await getAuthToken();
|
||||
const body = {};
|
||||
const defaultData: TransactionSummaryData = {
|
||||
totalCount: 0,
|
||||
totalGasCostWei: "0",
|
||||
totalGasUnitsUsed: "0",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${THIRDWEB_ENGINE_CLOUD_URL}/v1/transactions/analytics-summary`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": props.clientId,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 400) {
|
||||
console.error("Unauthorized fetching transaction summary");
|
||||
return defaultData;
|
||||
}
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Error fetching transaction summary: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as AnalyticsSummaryApiResponse;
|
||||
|
||||
return {
|
||||
totalCount: data.result.summary.totalCount ?? 0,
|
||||
totalGasCostWei: data.result.summary.totalGasCostWei ?? "0",
|
||||
totalGasUnitsUsed: data.result.summary.totalGasUnitsUsed ?? "0",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch transaction summary:", error);
|
||||
return defaultData;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTransactionsChart({
|
||||
teamId,
|
||||
clientId,
|
||||
from,
|
||||
to,
|
||||
interval,
|
||||
}: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
interval: "day" | "week";
|
||||
}): Promise<TransactionStats[]> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const filters = {
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
resolution: interval,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${THIRDWEB_ENGINE_CLOUD_URL}/v1/transactions/analytics`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-team-id": teamId,
|
||||
"x-client-id": clientId,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify(filters),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// TODO - need to handle this error state, like we do with the connect charts
|
||||
throw new Error(
|
||||
`Error fetching transactions chart data: ${response.status} ${response.statusText} - ${await response.text().catch(() => "Unknown error")}`,
|
||||
);
|
||||
}
|
||||
|
||||
type TransactionsChartResponse = {
|
||||
result: {
|
||||
analytics: Array<{
|
||||
timeBucket: string;
|
||||
chainId: string;
|
||||
count: number;
|
||||
}>;
|
||||
metadata: {
|
||||
resolution: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const data = (await response.json()) as TransactionsChartResponse;
|
||||
|
||||
return data.result.analytics.map((stat) => ({
|
||||
date: stat.timeBucket,
|
||||
chainId: Number(stat.chainId),
|
||||
count: stat.count,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getSingleTransaction({
|
||||
teamId,
|
||||
clientId,
|
||||
transactionId,
|
||||
}: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
transactionId: string;
|
||||
}): Promise<Transaction | undefined> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const filters = {
|
||||
filters: [
|
||||
{
|
||||
field: "id",
|
||||
values: [transactionId],
|
||||
operation: "OR",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${THIRDWEB_ENGINE_CLOUD_URL}/v1/transactions/search`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-team-id": teamId,
|
||||
"x-client-id": clientId,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify(filters),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// TODO - need to handle this error state, like we do with the connect charts
|
||||
throw new Error(
|
||||
`Error fetching single transaction data: ${response.status} ${response.statusText} - ${await response.text().catch(() => "Unknown error")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()).result as TransactionsResponse;
|
||||
|
||||
return data.transactions[0];
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { getFiltersFromSearchParams } from "@/lib/time";
|
||||
|
||||
export function getTxAnalyticsFiltersFromSearchParams(params: {
|
||||
from?: string | undefined | string[];
|
||||
to?: string | undefined | string[];
|
||||
interval?: string | undefined | string[];
|
||||
}) {
|
||||
return getFiltersFromSearchParams({
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
interval: params.interval,
|
||||
defaultRange: "last-30",
|
||||
});
|
||||
}
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
"use client";
|
||||
|
||||
import type { Project } from "@/api/projects";
|
||||
import { THIRDWEB_VAULT_URL } from "@/constants/env";
|
||||
import {
|
||||
type VaultClient,
|
||||
createAccessToken,
|
||||
createVaultClient,
|
||||
} from "@thirdweb-dev/vault-sdk";
|
||||
import { updateProjectClient } from "../../../../../../../../@3rdweb-sdk/react/hooks/useApi";
|
||||
|
||||
const SERVER_WALLET_ACCESS_TOKEN_PURPOSE =
|
||||
"Access Token for All Server Wallets";
|
||||
|
||||
export const SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE =
|
||||
"Management Token for Dashboard";
|
||||
|
||||
let vc: VaultClient | null = null;
|
||||
|
||||
export async function initVaultClient() {
|
||||
if (vc) {
|
||||
return vc;
|
||||
}
|
||||
vc = await createVaultClient({
|
||||
baseUrl: THIRDWEB_VAULT_URL,
|
||||
});
|
||||
return vc;
|
||||
}
|
||||
|
||||
export async function createWalletAccessToken(props: {
|
||||
project: Project;
|
||||
adminKey: string;
|
||||
vaultClient: VaultClient;
|
||||
}) {
|
||||
return createAccessToken({
|
||||
client: props.vaultClient,
|
||||
request: {
|
||||
options: {
|
||||
expiresAt: new Date(
|
||||
Date.now() + 60 * 60 * 1000 * 24 * 365 * 1000,
|
||||
).toISOString(), // 100 years from now
|
||||
policies: [
|
||||
{
|
||||
type: "eoa:read",
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "eoa:create",
|
||||
requiredMetadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "eoa:signMessage",
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "eoa:signTransaction",
|
||||
payloadPatterns: {},
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "eoa:signTypedData",
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "eoa:signStructuredMessage",
|
||||
structuredPatterns: {
|
||||
useropV06: {},
|
||||
useropV07: {},
|
||||
},
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
purpose: SERVER_WALLET_ACCESS_TOKEN_PURPOSE,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
adminKey: props.adminKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createManagementAccessToken(props: {
|
||||
project: Project;
|
||||
adminKey: string;
|
||||
rotationCode: string;
|
||||
vaultClient: VaultClient;
|
||||
}) {
|
||||
const res = await createAccessToken({
|
||||
client: props.vaultClient,
|
||||
request: {
|
||||
options: {
|
||||
expiresAt: new Date(
|
||||
Date.now() + 60 * 60 * 1000 * 24 * 365 * 1000,
|
||||
).toISOString(), // 100 years from now
|
||||
policies: [
|
||||
{
|
||||
type: "eoa:read",
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "eoa:create",
|
||||
requiredMetadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "accessToken:read",
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
],
|
||||
revealSensitive: false,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
purpose: SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
adminKey: props.adminKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (res.success) {
|
||||
const data = res.data;
|
||||
// store the management access token in the project
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...props.project.services,
|
||||
{
|
||||
name: "engineCloud",
|
||||
managementAccessToken: data.accessToken,
|
||||
maskedAdminKey: maskSecret(props.adminKey),
|
||||
rotationCode: props.rotationCode,
|
||||
actions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export function maskSecret(secret: string) {
|
||||
return `${secret.substring(0, 11)}...${secret.substring(secret.length - 5)}`;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { getProject } from "@/api/projects";
|
||||
import { THIRDWEB_VAULT_URL } from "@/constants/env";
|
||||
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
|
||||
import { TransactionsAnalyticsPageContent } from "./analytics/analytics-page";
|
||||
import { EngineChecklist } from "./analytics/ftux.client";
|
||||
import { TransactionAnalyticsSummary } from "./analytics/summary";
|
||||
import {
|
||||
type TransactionSummaryData,
|
||||
getTransactionAnalyticsSummary,
|
||||
} from "./lib/analytics";
|
||||
import type { Wallet } from "./server-wallets/wallet-table/types";
|
||||
|
||||
export default async function TransactionsAnalyticsPage(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
searchParams: Promise<{
|
||||
from?: string | string[] | undefined;
|
||||
to?: string | string[] | undefined;
|
||||
interval?: string | string[] | undefined;
|
||||
testTxWithWallet?: string | string[] | undefined;
|
||||
}>;
|
||||
}) {
|
||||
const [params, searchParams, authToken] = await Promise.all([
|
||||
props.params,
|
||||
props.searchParams,
|
||||
getAuthToken(),
|
||||
]);
|
||||
|
||||
if (!authToken) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [vaultClient, project] = await Promise.all([
|
||||
createVaultClient({
|
||||
baseUrl: THIRDWEB_VAULT_URL,
|
||||
}).catch(() => undefined),
|
||||
getProject(params.team_slug, params.project_slug),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
if (!vaultClient) {
|
||||
return <div>Error: Failed to connect to Vault</div>;
|
||||
}
|
||||
|
||||
const projectEngineCloudService = project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
|
||||
const managementAccessToken =
|
||||
projectEngineCloudService?.managementAccessToken;
|
||||
|
||||
const eoas = managementAccessToken
|
||||
? await listEoas({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
})
|
||||
: { data: { items: [] }, error: null, success: true };
|
||||
|
||||
const wallets = eoas.data?.items as Wallet[] | undefined;
|
||||
|
||||
let initialData: TransactionSummaryData | undefined;
|
||||
if (wallets && wallets.length > 0) {
|
||||
const summary = await getTransactionAnalyticsSummary({
|
||||
teamId: project.teamId,
|
||||
clientId: project.publishableKey,
|
||||
}).catch(() => undefined);
|
||||
initialData = summary;
|
||||
}
|
||||
const hasTransactions = initialData ? initialData.totalCount > 0 : false;
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<EngineChecklist
|
||||
teamSlug={params.team_slug}
|
||||
managementAccessToken={managementAccessToken ?? undefined}
|
||||
hasTransactions={hasTransactions}
|
||||
project={project}
|
||||
wallets={wallets ?? []}
|
||||
testTxWithWallet={searchParams.testTxWithWallet as string | undefined}
|
||||
/>
|
||||
{hasTransactions && !searchParams.testTxWithWallet && (
|
||||
<TransactionAnalyticsSummary
|
||||
initialData={initialData}
|
||||
teamId={project.teamId}
|
||||
clientId={project.publishableKey}
|
||||
/>
|
||||
)}
|
||||
<div className="h-10" />
|
||||
<TransactionsAnalyticsPageContent
|
||||
teamSlug={params.team_slug}
|
||||
searchParams={searchParams}
|
||||
project={project}
|
||||
showAnalytics={hasTransactions && !searchParams.testTxWithWallet}
|
||||
wallets={wallets}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
import type { Project } from "@/api/projects";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createEoa } from "@thirdweb-dev/vault-sdk";
|
||||
import { Loader2Icon, WalletIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { engineCloudProxy } from "../../../../../../../../../@/actions/proxies";
|
||||
import { useTrack } from "../../../../../../../../../hooks/analytics/useTrack";
|
||||
import { initVaultClient } from "../../lib/vault.client";
|
||||
|
||||
export default function CreateServerWallet(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const [label, setLabel] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const trackEvent = useTrack();
|
||||
|
||||
const createEoaMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
managementAccessToken,
|
||||
label,
|
||||
}: {
|
||||
managementAccessToken: string;
|
||||
label: string;
|
||||
}) => {
|
||||
trackEvent({
|
||||
category: "engine-cloud",
|
||||
action: "create_server_wallet",
|
||||
});
|
||||
|
||||
const vaultClient = await initVaultClient();
|
||||
|
||||
const eoa = await createEoa({
|
||||
request: {
|
||||
options: {
|
||||
metadata: {
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
type: "server-wallet",
|
||||
label,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
},
|
||||
client: vaultClient,
|
||||
});
|
||||
|
||||
if (!eoa.success) {
|
||||
throw new Error("Failed to create eoa");
|
||||
}
|
||||
|
||||
// no need to await this, it's not blocking
|
||||
engineCloudProxy({
|
||||
pathname: "/cache/smart-account",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-team-id": props.project.teamId,
|
||||
"x-client-id": props.project.publishableKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
signerAddress: eoa.data.address,
|
||||
}),
|
||||
}).catch((err) => {
|
||||
console.warn("failed to cache server wallet", err);
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
setModalOpen(false);
|
||||
|
||||
return eoa;
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateServerWallet = async () => {
|
||||
if (!props.managementAccessToken) {
|
||||
router.push(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud/vault`,
|
||||
);
|
||||
} else {
|
||||
await createEoaMutation.mutateAsync({
|
||||
managementAccessToken: props.managementAccessToken,
|
||||
label,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = createEoaMutation.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() =>
|
||||
props.managementAccessToken
|
||||
? setModalOpen(true)
|
||||
: router.push(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud/vault`,
|
||||
)
|
||||
}
|
||||
className="flex flex-row items-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<WalletIcon className="size-4" />
|
||||
)}
|
||||
{props.managementAccessToken
|
||||
? isLoading
|
||||
? "Creating..."
|
||||
: "Create Server Wallet"
|
||||
: "Get Started"}
|
||||
</Button>
|
||||
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="p-0">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>Create server wallet</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a label for your server wallet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 px-6">
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Wallet label (optional)"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setModalOpen(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreateServerWallet}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
"use client";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { THIRDWEB_ENGINE_CLOUD_URL } from "@/constants/env";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "../../../../../../../../../@/components/ui/alert";
|
||||
|
||||
export function TryItOut() {
|
||||
const [activeTab, setActiveTab] = useState<string>("sdk");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card p-6">
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="flex flex-1 flex-col gap-4 rounded-lg rounded-b-none lg:flex-row lg:justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
Usage from your backend
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Send transactions from your server wallets using the thirdweb SDK
|
||||
or the HTTP API directly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
name: "thirdweb SDK",
|
||||
onClick: () => setActiveTab("sdk"),
|
||||
isActive: activeTab === "sdk",
|
||||
},
|
||||
{
|
||||
name: "Curl",
|
||||
onClick: () => setActiveTab("curl"),
|
||||
isActive: activeTab === "curl",
|
||||
},
|
||||
{
|
||||
name: "JavaScript",
|
||||
onClick: () => setActiveTab("js"),
|
||||
isActive: activeTab === "js",
|
||||
},
|
||||
{
|
||||
name: "Python",
|
||||
onClick: () => setActiveTab("python"),
|
||||
isActive: activeTab === "python",
|
||||
},
|
||||
{
|
||||
name: "Go",
|
||||
onClick: () => setActiveTab("go"),
|
||||
isActive: activeTab === "go",
|
||||
},
|
||||
{
|
||||
name: "C#",
|
||||
onClick: () => setActiveTab("csharp"),
|
||||
isActive: activeTab === "csharp",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
{activeTab === "sdk" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Alert variant="info" className="bg-background">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>Using the thirdweb SDK on the backend</AlertTitle>
|
||||
<AlertDescription className="leading-loose">
|
||||
<p className="text-foreground text-sm">
|
||||
You can use the full TypeScript thirdweb SDK in your backend,
|
||||
allowing you to use:{" "}
|
||||
<ul className="ml-2 list-disc py-2 pl-4">
|
||||
<li>
|
||||
The full catalog of{" "}
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/references/typescript/v5/functions#extensions"
|
||||
className="text-blue-500"
|
||||
target="_blank"
|
||||
>
|
||||
extension functions
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
The{" "}
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/references/typescript/v5/prepareContractCall"
|
||||
className="text-blue-500"
|
||||
target="_blank"
|
||||
>
|
||||
prepareContractCall
|
||||
</Link>{" "}
|
||||
function to encode your transactions
|
||||
</li>
|
||||
<li>
|
||||
The full{" "}
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/references/typescript/v5/Account"
|
||||
className="text-blue-500"
|
||||
target="_blank"
|
||||
>
|
||||
account
|
||||
</Link>{" "}
|
||||
interface, predefined chains, and more
|
||||
</li>
|
||||
</ul>
|
||||
The SDK handles encoding your transactions, signing them to
|
||||
Engine and polling for status.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<h3 className="font-semibold text-lg tracking-tight">
|
||||
Installation
|
||||
</h3>
|
||||
<CodeClient
|
||||
lang="shell"
|
||||
code={"npm install thirdweb"}
|
||||
className="bg-background"
|
||||
/>
|
||||
<h3 className="font-semibold text-lg tracking-tight">
|
||||
Usage example: Minting a ERC1155 NFT to a user
|
||||
</h3>
|
||||
<CodeClient
|
||||
lang="ts"
|
||||
code={sdkExample()}
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "curl" && (
|
||||
<CodeClient
|
||||
lang="bash"
|
||||
code={curlExample()}
|
||||
className="bg-background"
|
||||
/>
|
||||
)}
|
||||
{activeTab === "js" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
A lightweight, type safe wrapper package of the Engine HTTP API is
|
||||
available on{" "}
|
||||
<Link
|
||||
href="https://www.npmjs.com/package/@thirdweb-dev/engine"
|
||||
className="text-blue-500"
|
||||
target="_blank"
|
||||
>
|
||||
NPM
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<CodeClient
|
||||
lang="js"
|
||||
code={jsExample()}
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "python" && (
|
||||
<CodeClient
|
||||
lang="python"
|
||||
code={pythonExample()}
|
||||
className="bg-background"
|
||||
/>
|
||||
)}
|
||||
{activeTab === "go" && (
|
||||
<CodeClient lang="go" code={goExample()} className="bg-background" />
|
||||
)}
|
||||
{activeTab === "csharp" && (
|
||||
<CodeClient
|
||||
lang="csharp"
|
||||
code={csharpExample()}
|
||||
className="bg-background"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sdkExample = () => `\
|
||||
import { createThirdwebClient, sendTransaction, getContract, Engine } from "thirdweb";
|
||||
import { baseSepolia } from "thirdweb/chains";
|
||||
import { claimTo } from "thirdweb/extensions/1155";
|
||||
|
||||
// Create a thirdweb client
|
||||
const client = createThirdwebClient({
|
||||
secretKey: "<your-project-secret-key>",
|
||||
});
|
||||
|
||||
// Create a server wallet
|
||||
const serverWallet = Engine.serverWallet({
|
||||
client,
|
||||
address: "<your-server-wallet-address>",
|
||||
vaultAccessToken: "<your-vault-access-token>",
|
||||
});
|
||||
|
||||
// Prepare the transaction
|
||||
const transaction = claimTo({
|
||||
contract: getContract({
|
||||
client,
|
||||
address: "0x...", // Address of the ERC1155 token contract
|
||||
chain: baseSepolia, // Chain of the ERC1155 token contract
|
||||
}),
|
||||
to: "0x...", // The address of the user to mint to
|
||||
tokenId: 0n, // The token ID of the NFT to mint
|
||||
quantity: 1n, // The quantity of NFTs to mint
|
||||
});
|
||||
|
||||
// Enqueue the transaction via Engine
|
||||
const { transactionId } = await serverWallet.enqueueTransaction({
|
||||
transaction,
|
||||
});
|
||||
|
||||
// Get the execution status of the transaction at any point in time
|
||||
const executionResult = await Engine.getTransactionStatus({
|
||||
client,
|
||||
transactionId,
|
||||
});
|
||||
|
||||
// Utility function to poll for the transaction to be submitted onchain
|
||||
const txHash = await Engine.waitForTransactionHash({
|
||||
client,
|
||||
transactionId,
|
||||
});
|
||||
console.log("Transaction hash:", result.transactionHash);
|
||||
`;
|
||||
|
||||
const curlExample = () => `\
|
||||
curl -X POST "${THIRDWEB_ENGINE_CLOUD_URL}/v1/write/contract" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "x-secret-key: <your-project-secret-key>" \\
|
||||
-H "x-vault-access-token: <your-vault-access-token>" \\
|
||||
-d '{
|
||||
"executionOptions": {
|
||||
"from": "<your-server-wallet-address>",
|
||||
"chainId": "84532"
|
||||
},
|
||||
"params": [
|
||||
{
|
||||
"contractAddress": "0x...",
|
||||
"method": "function mintTo(address to, uint256 amount)",
|
||||
"params": ["0x...", "100"]
|
||||
}
|
||||
]
|
||||
}'`;
|
||||
|
||||
const jsExample = () => `\
|
||||
const response = await fetch(
|
||||
"${THIRDWEB_ENGINE_CLOUD_URL}/v1/write/contract",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-secret-key": "<your-project-secret-key>",
|
||||
"x-vault-access-token": "<your-vault-access-token>"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"executionOptions": {
|
||||
"from": "<your-server-wallet-address>",
|
||||
"chainId": "84532"
|
||||
},
|
||||
"params": [
|
||||
{
|
||||
"contractAddress": "0x...",
|
||||
"method": "function mintTo(address to, uint256 amount)",
|
||||
"params": ["0x...", "100"]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
);`;
|
||||
|
||||
const pythonExample = () => `\
|
||||
import requests
|
||||
import json
|
||||
|
||||
url = "${THIRDWEB_ENGINE_CLOUD_URL}/v1/write/contract"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-secret-key": "<your-project-secret-key>",
|
||||
"x-vault-access-token": "<your-vault-access-token>"
|
||||
}
|
||||
payload = {
|
||||
"executionOptions": {
|
||||
"from": "<your-server-wallet-address>",
|
||||
"chainId": "84532"
|
||||
},
|
||||
"params": [
|
||||
{
|
||||
"contractAddress": "0x...",
|
||||
"method": "function mintTo(address to, uint256 amount)",
|
||||
"params": ["0x...", "100"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
result = response.json()`;
|
||||
|
||||
const goExample = () => `\
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
url := "${THIRDWEB_ENGINE_CLOUD_URL}/v1/write/contract"
|
||||
|
||||
// Create the request payload
|
||||
type Param struct {
|
||||
ContractAddress string \`json:"contractAddress"\`
|
||||
Method string \`json:"method"\`
|
||||
Params []string \`json:"params"\`
|
||||
}
|
||||
|
||||
type RequestBody struct {
|
||||
ExecutionOptions struct {
|
||||
From string \`json:"from"\`
|
||||
ChainId string \`json:"chainId"\`
|
||||
} \`json:"executionOptions"\`
|
||||
Params []Param \`json:"params"\`
|
||||
}
|
||||
|
||||
requestBody := RequestBody{
|
||||
Params: []Param{
|
||||
{
|
||||
ContractAddress: "0x...",
|
||||
Method: "function mintTo(address to, uint256 amount)",
|
||||
Params: []string{"0x...", "100"},
|
||||
},
|
||||
},
|
||||
}
|
||||
requestBody.ExecutionOptions.From = "<your-server-wallet-address>"
|
||||
requestBody.ExecutionOptions.ChainId = "84532"
|
||||
|
||||
jsonData, _ := json.Marshal(requestBody)
|
||||
|
||||
// Create the HTTP request
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-secret-key", "<your-project-secret-key>")
|
||||
req.Header.Set("x-vault-access-token", "<your-vault-access-token>")
|
||||
|
||||
// Send the request
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Println("Error:", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Process the response
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
fmt.Println("Response:", result)
|
||||
}`;
|
||||
|
||||
const csharpExample = () => `\
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
class Program
|
||||
{
|
||||
static async Task Main()
|
||||
{
|
||||
var url = "${THIRDWEB_ENGINE_CLOUD_URL}/v1/write/contract";
|
||||
|
||||
var requestData = new
|
||||
{
|
||||
executionOptions = new
|
||||
{
|
||||
from = "<your-server-wallet-address>",
|
||||
chainId = "84532"
|
||||
},
|
||||
@params = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
contractAddress = "0x...",
|
||||
method = "function mintTo(address to, uint256 amount)",
|
||||
@params = new[] { "0x...", "100" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestData);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
httpClient.DefaultRequestHeaders.Add("x-secret-key", "<your-project-secret-key>");
|
||||
httpClient.DefaultRequestHeaders.Add("x-vault-access-token", "<your-vault-access-token>");
|
||||
|
||||
var response = await httpClient.PostAsync(url, content);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Console.WriteLine(responseContent);
|
||||
}
|
||||
}`;
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { getProject } from "@/api/projects";
|
||||
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
|
||||
import { notFound } from "next/navigation";
|
||||
import { THIRDWEB_VAULT_URL } from "../../../../../../../../@/constants/env";
|
||||
import { getAuthToken } from "../../../../../../api/lib/getAuthToken";
|
||||
import type { Wallet } from "./wallet-table/types";
|
||||
import { ServerWalletsTable } from "./wallet-table/wallet-table";
|
||||
|
||||
export default async function TransactionsServerWalletsPage(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
}) {
|
||||
const vaultClient = await createVaultClient({
|
||||
baseUrl: THIRDWEB_VAULT_URL,
|
||||
});
|
||||
|
||||
const { team_slug, project_slug } = await props.params;
|
||||
const [authToken, project] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getProject(team_slug, project_slug),
|
||||
]);
|
||||
|
||||
if (!project || !authToken) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const projectEngineCloudService = project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
|
||||
const managementAccessToken =
|
||||
projectEngineCloudService?.managementAccessToken;
|
||||
|
||||
const eoas = managementAccessToken
|
||||
? await listEoas({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
})
|
||||
: { data: { items: [] }, error: null, success: true };
|
||||
|
||||
return (
|
||||
<>
|
||||
{eoas.error ? (
|
||||
<div>Error: {eoas.error.message}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-8">
|
||||
<ServerWalletsTable
|
||||
wallets={eoas.data.items as Wallet[]}
|
||||
project={project}
|
||||
teamSlug={team_slug}
|
||||
managementAccessToken={managementAccessToken ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
export type Wallet = {
|
||||
id: string;
|
||||
address: string;
|
||||
metadata: {
|
||||
type: string;
|
||||
projectId: string;
|
||||
label?: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import type { Project } from "@/api/projects";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { format } from "date-fns/format";
|
||||
import { SendIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_FACTORY_V0_7,
|
||||
predictSmartAccountAddress,
|
||||
} from "thirdweb/wallets/smart";
|
||||
import { Button } from "../../../../../../../../../@/components/ui/button";
|
||||
import { useDashboardRouter } from "../../../../../../../../../@/lib/DashboardRouter";
|
||||
import { useV5DashboardChain } from "../../../../../../../../../lib/v5-adapter";
|
||||
import CreateServerWallet from "../components/create-server-wallet.client";
|
||||
import type { Wallet } from "./types";
|
||||
|
||||
export function ServerWalletsTableUI({
|
||||
wallets,
|
||||
project,
|
||||
teamSlug,
|
||||
managementAccessToken,
|
||||
}: {
|
||||
wallets: Wallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
}) {
|
||||
const [showSigners, setShowSigners] = useState(false);
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="flex flex-row items-center gap-4 px-6 py-6">
|
||||
<div className="flex flex-1 flex-col gap-4 rounded-lg rounded-b-none lg:flex-row lg:justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
Server Wallets
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Create and manage server wallets for you project
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<label htmlFor="show-signers" className="cursor-pointer text-sm">
|
||||
Show Signer Addresses
|
||||
</label>
|
||||
<Switch
|
||||
id="show-signers"
|
||||
checked={showSigners}
|
||||
onCheckedChange={() => setShowSigners(!showSigners)}
|
||||
/>
|
||||
</div>
|
||||
<CreateServerWallet
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
managementAccessToken={managementAccessToken}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TableContainer className="rounded-none border-x-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Address</TableHead>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead className="text-right">Created At</TableHead>
|
||||
<TableHead className="text-right">Updated At</TableHead>
|
||||
<TableHead className="text-right">Send test tx</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{wallets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center">
|
||||
No server wallets found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
wallets.map((wallet) => (
|
||||
<TableRow key={wallet.id} className="hover:bg-accent/50">
|
||||
<TableCell>
|
||||
{showSigners ? (
|
||||
<WalletAddress address={wallet.address} />
|
||||
) : (
|
||||
<SmartAccountCell wallet={wallet} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{wallet.metadata.label || "none"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<WalletDateCell date={wallet.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<WalletDateCell date={wallet.updatedAt} />
|
||||
</TableCell>
|
||||
<TableCell className="flex justify-end">
|
||||
<SendTestTransaction
|
||||
wallet={wallet}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SmartAccountCell({ wallet }: { wallet: Wallet }) {
|
||||
const chainId = 1; // TODO: add chain switcher for balance + smart account address
|
||||
const chain = useV5DashboardChain(chainId);
|
||||
|
||||
const smartAccountAddressQuery = useQuery({
|
||||
queryKey: ["smart-account-address", wallet.address],
|
||||
queryFn: async () => {
|
||||
const smartAccountAddress = await predictSmartAccountAddress({
|
||||
client: getThirdwebClient(undefined),
|
||||
adminAddress: wallet.address,
|
||||
chain,
|
||||
factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7,
|
||||
});
|
||||
return smartAccountAddress;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{smartAccountAddressQuery.data ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletAddress address={smartAccountAddressQuery.data} />
|
||||
<Badge variant={"default"}>Smart Account</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-4 w-20" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletDateCell({ date }: { date: string }) {
|
||||
if (!date) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const dateObj = new Date(date);
|
||||
return (
|
||||
<ToolTipLabel label={format(dateObj, "PP pp z")}>
|
||||
<p>{formatDistanceToNowStrict(dateObj, { addSuffix: true })}</p>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
function SendTestTransaction(props: {
|
||||
wallet: Wallet;
|
||||
teamSlug: string;
|
||||
project: Project;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud?testTxWithWallet=${props.wallet.id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SendIcon className="size-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import type { Project } from "@/api/projects";
|
||||
import type { Wallet } from "./types";
|
||||
import { ServerWalletsTableUI } from "./wallet-table-ui.client";
|
||||
|
||||
export function ServerWalletsTable({
|
||||
wallets,
|
||||
project,
|
||||
teamSlug,
|
||||
managementAccessToken,
|
||||
}: {
|
||||
wallets: Wallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<ServerWalletsTableUI
|
||||
wallets={wallets}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
managementAccessToken={managementAccessToken}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function TransactionLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { team_slug: string; project_slug: string };
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/team/${params.team_slug}/${params.project_slug}/engine/cloud`}
|
||||
className="flex items-center gap-1 text-muted-foreground text-sm hover:text-foreground"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
Back to Transactions
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { getProject } from "@/api/projects";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getSingleTransaction } from "../../lib/analytics";
|
||||
import { TransactionDetailsUI } from "./transaction-details-ui";
|
||||
|
||||
export default async function TransactionPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ team_slug: string; project_slug: string; id: string }>;
|
||||
}) {
|
||||
const { team_slug, project_slug, id } = await params;
|
||||
|
||||
const project = await getProject(team_slug, project_slug);
|
||||
|
||||
if (!project) {
|
||||
redirect(`/team/${team_slug}`);
|
||||
}
|
||||
|
||||
const transactionData = await getSingleTransaction({
|
||||
teamId: project.teamId,
|
||||
clientId: project.publishableKey,
|
||||
transactionId: id,
|
||||
});
|
||||
|
||||
if (!transactionData) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-2">
|
||||
<TransactionDetailsUI
|
||||
transaction={transactionData}
|
||||
teamSlug={team_slug}
|
||||
project={project}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
"use client";
|
||||
|
||||
import type { Project } from "@/api/projects";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { ExternalLinkIcon, InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toEther } from "thirdweb";
|
||||
import { ChainIconClient } from "../../../../../../../../../components/icons/ChainIcon";
|
||||
import { statusDetails } from "../../analytics/tx-table/tx-table-ui";
|
||||
import type { Transaction } from "../../analytics/tx-table/types";
|
||||
|
||||
export function TransactionDetailsUI({
|
||||
transaction,
|
||||
}: {
|
||||
transaction: Transaction;
|
||||
teamSlug: string;
|
||||
project: Project;
|
||||
}) {
|
||||
const thirdwebClient = useThirdwebClient();
|
||||
const { idToChain } = useAllChainsData();
|
||||
|
||||
// Extract relevant data from transaction
|
||||
const {
|
||||
id,
|
||||
chainId,
|
||||
from,
|
||||
transactionHash,
|
||||
confirmedAt,
|
||||
createdAt,
|
||||
errorMessage,
|
||||
executionParams,
|
||||
executionResult,
|
||||
} = transaction;
|
||||
|
||||
const status = executionResult?.status as keyof typeof statusDetails;
|
||||
|
||||
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
|
||||
const explorer = chain?.explorers?.[0];
|
||||
|
||||
// Calculate time difference between creation and confirmation
|
||||
const confirmationTime =
|
||||
confirmedAt && createdAt
|
||||
? new Date(confirmedAt).getTime() - new Date(createdAt).getTime()
|
||||
: null;
|
||||
|
||||
// Determine sender and signer addresses
|
||||
const senderAddress = executionParams?.smartAccountAddress || from || "";
|
||||
const signerAddress = executionParams?.signerAddress || from || "";
|
||||
|
||||
// Gas information
|
||||
const gasUsed =
|
||||
executionResult && "actualGasUsed" in executionResult
|
||||
? `${executionResult.actualGasUsed}`
|
||||
: "N/A";
|
||||
|
||||
const gasCost =
|
||||
executionResult && "actualGasCost" in executionResult
|
||||
? `${toEther(BigInt(executionResult.actualGasCost || "0"))} ${chain?.nativeCurrency.symbol || ""}`
|
||||
: "N/A";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<h1 className="font-semibold text-2xl tracking-tight">
|
||||
Transaction Details
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Transaction Info Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Transaction Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
|
||||
<div className="w-full text-muted-foreground text-sm md:w-1/3">
|
||||
Status
|
||||
</div>
|
||||
<div className="md:w-2/3">
|
||||
{status && (
|
||||
<ToolTipLabel
|
||||
hoverable
|
||||
label={
|
||||
errorMessage ||
|
||||
(status === "CONFIRMED" && confirmedAt
|
||||
? `Completed ${format(new Date(confirmedAt), "PP pp")}`
|
||||
: undefined)
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
variant={statusDetails[status].type}
|
||||
className="gap-2"
|
||||
>
|
||||
{statusDetails[status].name}
|
||||
{errorMessage && <InfoIcon className="size-3" />}
|
||||
</Badge>
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
|
||||
<div className="w-full text-muted-foreground text-sm md:w-1/3">
|
||||
Transaction ID
|
||||
</div>
|
||||
<div className="md:w-2/3">
|
||||
<CopyTextButton
|
||||
textToCopy={id}
|
||||
copyIconPosition="left"
|
||||
textToShow={`${id.slice(0, 8)}...${id.slice(-6)}`}
|
||||
variant="ghost"
|
||||
tooltip="Copy transaction ID"
|
||||
className="font-mono text-muted-foreground text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
|
||||
<div className="w-full text-muted-foreground text-sm md:w-1/3">
|
||||
Transaction Hash
|
||||
</div>
|
||||
<div className="md:w-2/3">
|
||||
{transactionHash ? (
|
||||
<div>
|
||||
{explorer ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
asChild
|
||||
className="-translate-x-2 gap-2 font-mono"
|
||||
size="sm"
|
||||
>
|
||||
<Link
|
||||
href={`${explorer.url}/tx/${transactionHash}`}
|
||||
target="_blank"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{`${transactionHash.slice(0, 8)}...${transactionHash.slice(-6)}`}{" "}
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<CopyTextButton
|
||||
textToCopy={transactionHash}
|
||||
copyIconPosition="left"
|
||||
textToShow={`${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`}
|
||||
variant="ghost"
|
||||
tooltip="Copy transaction hash"
|
||||
className="font-mono text-muted-foreground text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm">Not available yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
|
||||
<div className="w-full text-muted-foreground text-sm md:w-1/3">
|
||||
Network
|
||||
</div>
|
||||
<div className="md:w-2/3">
|
||||
{chain ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<ChainIconClient
|
||||
className="size-4"
|
||||
client={thirdwebClient}
|
||||
src={chain.icon?.url}
|
||||
/>
|
||||
<span className="text-sm">{chain.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>Chain ID: {chainId || "Unknown"}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Sender Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
|
||||
<div className="w-full text-muted-foreground text-sm md:w-1/3">
|
||||
Sender Address
|
||||
</div>
|
||||
<div className="md:w-2/3">
|
||||
<WalletAddress address={senderAddress} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
|
||||
<div className="w-full text-muted-foreground text-sm md:w-1/3">
|
||||
Signer Address
|
||||
</div>
|
||||
<div className="md:w-2/3">
|
||||
<WalletAddress address={signerAddress} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Transaction Parameters</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{transaction.transactionParams &&
|
||||
transaction.transactionParams.length > 0 ? (
|
||||
<CodeClient
|
||||
code={JSON.stringify(transaction.transactionParams, null, 2)}
|
||||
lang="json"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No transaction parameters available
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{errorMessage && (
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive text-lg">
|
||||
Error Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Timing Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
|
||||
<div className="w-full text-muted-foreground text-sm md:w-1/3">
|
||||
Created At
|
||||
</div>
|
||||
<div className="md:w-2/3">
|
||||
{createdAt ? (
|
||||
<p className="text-sm">
|
||||
{formatDistanceToNowStrict(new Date(createdAt), {
|
||||
addSuffix: true,
|
||||
})}{" "}
|
||||
({format(new Date(createdAt), "PP pp z")})
|
||||
</p>
|
||||
) : (
|
||||
"N/A"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
|
||||
<div className="w-full text-muted-foreground text-sm md:w-1/3">
|
||||
Confirmed At
|
||||
</div>
|
||||
<div className="text-sm md:w-2/3">
|
||||
{confirmedAt ? (
|
||||
<p>
|
||||
{formatDistanceToNowStrict(new Date(confirmedAt), {
|
||||
addSuffix: true,
|
||||
})}{" "}
|
||||
({format(new Date(confirmedAt), "PP pp z")})
|
||||
</p>
|
||||
) : (
|
||||
"Pending"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmationTime && (
|
||||
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
|
||||
<div className="w-full text-muted-foreground text-sm md:w-1/3">
|
||||
Confirmation Time
|
||||
</div>
|
||||
<div className="text-sm md:w-2/3">
|
||||
{Math.floor(confirmationTime / 1000)} seconds
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Gas Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
|
||||
<div className="w-full text-muted-foreground text-sm md:w-1/3">
|
||||
Gas Used
|
||||
</div>
|
||||
<div className="text-sm md:w-2/3">{gasUsed}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
|
||||
<div className="w-full text-muted-foreground text-sm md:w-1/3">
|
||||
Gas Cost
|
||||
</div>
|
||||
<div className="text-sm md:w-2/3">{gasCost}</div>
|
||||
</div>
|
||||
|
||||
{transaction.confirmedAtBlockNumber && (
|
||||
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
|
||||
<div className="w-full text-muted-foreground text-sm md:w-1/3">
|
||||
Block Number
|
||||
</div>
|
||||
<div className="text-sm md:w-2/3">
|
||||
{transaction.confirmedAtBlockNumber}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
import type { Project } from "@/api/projects";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createServiceAccount } from "@thirdweb-dev/vault-sdk";
|
||||
import { CheckIcon, DownloadIcon, Loader2Icon, LockIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTrack } from "../../../../../../../../../hooks/analytics/useTrack";
|
||||
import {
|
||||
createManagementAccessToken,
|
||||
createWalletAccessToken,
|
||||
initVaultClient,
|
||||
maskSecret,
|
||||
} from "../../lib/vault.client";
|
||||
|
||||
export default function CreateVaultAccountButton(props: {
|
||||
project: Project;
|
||||
onUserAccessTokenCreated?: (userAccessToken: string) => void;
|
||||
}) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [keysConfirmed, setKeysConfirmed] = useState(false);
|
||||
const [keysDownloaded, setKeysDownloaded] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
const trackEvent = useTrack();
|
||||
|
||||
const initialiseProjectWithVaultMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
setModalOpen(true);
|
||||
|
||||
trackEvent({
|
||||
category: "engine-cloud",
|
||||
action: "create_vault_account",
|
||||
});
|
||||
|
||||
const vaultClient = await initVaultClient();
|
||||
|
||||
const serviceAccount = await createServiceAccount({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
options: {
|
||||
metadata: {
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
purpose: "Thirdweb Project Server Wallet Service Account",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!serviceAccount.success) {
|
||||
throw new Error("Failed to create service account");
|
||||
}
|
||||
|
||||
const managementAccessTokenPromise = createManagementAccessToken({
|
||||
project: props.project,
|
||||
adminKey: serviceAccount.data.adminKey,
|
||||
rotationCode: serviceAccount.data.rotationCode,
|
||||
vaultClient,
|
||||
});
|
||||
|
||||
const userAccesTokenPromise = createWalletAccessToken({
|
||||
project: props.project,
|
||||
adminKey: serviceAccount.data.adminKey,
|
||||
vaultClient,
|
||||
});
|
||||
|
||||
const [userAccessTokenRes, managementAccessTokenRes] = await Promise.all([
|
||||
userAccesTokenPromise,
|
||||
managementAccessTokenPromise,
|
||||
]);
|
||||
|
||||
if (!managementAccessTokenRes.success || !userAccessTokenRes.success) {
|
||||
throw new Error("Failed to create access token");
|
||||
}
|
||||
|
||||
props.onUserAccessTokenCreated?.(userAccessTokenRes.data.accessToken);
|
||||
|
||||
return {
|
||||
serviceAccount: serviceAccount.data,
|
||||
userAccessToken: userAccessTokenRes.data,
|
||||
};
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
setModalOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateServerWallet = async () => {
|
||||
await initialiseProjectWithVaultMutation.mutateAsync();
|
||||
};
|
||||
|
||||
const handleDownloadKeys = () => {
|
||||
if (!initialiseProjectWithVaultMutation.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${initialiseProjectWithVaultMutation.data.serviceAccount.adminKey}\n\nVault Access Token:\n${initialiseProjectWithVaultMutation.data.userAccessToken.accessToken}\n`;
|
||||
const blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
const filename = `${props.project.name}-vault-keys.txt`;
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link); // Required for Firefox
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`Keys downloaded as ${filename}`);
|
||||
setKeysDownloaded(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
if (!keysConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setModalOpen(false);
|
||||
setKeysConfirmed(false);
|
||||
setKeysDownloaded(false);
|
||||
initialiseProjectWithVaultMutation.reset();
|
||||
// invalidate the page to force a reload
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const isLoading = initialiseProjectWithVaultMutation.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={handleCreateServerWallet}
|
||||
disabled={isLoading}
|
||||
className="flex flex-row items-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<LockIcon className="size-4" />
|
||||
)}
|
||||
{"Create Vault Admin Account"}
|
||||
</Button>
|
||||
|
||||
<Dialog open={modalOpen} onOpenChange={handleCloseModal} modal={true}>
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0"
|
||||
dialogCloseClassName={cn(!keysConfirmed && "hidden")}
|
||||
>
|
||||
{initialiseProjectWithVaultMutation.isPending ? (
|
||||
<>
|
||||
<DialogHeader className="p-6">
|
||||
<DialogTitle>Generating your Vault management keys</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-10">
|
||||
<Spinner className="size-8" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
This may take a few seconds.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : initialiseProjectWithVaultMutation.data ? (
|
||||
<div>
|
||||
<DialogHeader className="p-6">
|
||||
<DialogTitle>Save your Vault Admin Key</DialogTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
You'll need this key to create server wallets and access
|
||||
tokens.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 p-6 pt-0">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CopyTextButton
|
||||
textToCopy={
|
||||
initialiseProjectWithVaultMutation.data.serviceAccount
|
||||
.adminKey
|
||||
}
|
||||
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
|
||||
textToShow={maskSecret(
|
||||
initialiseProjectWithVaultMutation.data.serviceAccount
|
||||
.adminKey,
|
||||
)}
|
||||
copyIconPosition="right"
|
||||
tooltip="Copy Admin Key"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Download this key to your local machine or a password
|
||||
manager.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div>
|
||||
<h3 className="mb-2 font-medium text-sm">
|
||||
Vault Access Token
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2 ">
|
||||
<CopyTextButton
|
||||
textToCopy={
|
||||
initialiseProjectWithVaultMutation.data
|
||||
.userAccessToken.accessToken
|
||||
}
|
||||
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
|
||||
textToShow={maskSecret(
|
||||
initialiseProjectWithVaultMutation.data
|
||||
.userAccessToken.accessToken,
|
||||
)}
|
||||
copyIconPosition="right"
|
||||
tooltip="Copy Vault Access Token"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
This access token is used to sign transactions and
|
||||
messages from your backend. Can be revoked and recreated
|
||||
with your admin key.
|
||||
</p>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Secure your admin key</AlertTitle>
|
||||
<AlertDescription>
|
||||
These keys will not be displayed again. Store them securely
|
||||
as they provide access to your server wallets.
|
||||
</AlertDescription>
|
||||
<div className="h-4" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={handleDownloadKeys}
|
||||
className="flex h-auto items-center gap-2 p-0 text-sm text-success-text"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
{keysDownloaded ? "Key Downloaded" : "Download Admin Key"}
|
||||
</Button>
|
||||
{keysDownloaded && (
|
||||
<span className="text-success-text text-xs">
|
||||
<CheckIcon className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-4" />
|
||||
<CheckboxWithLabel className="text-foreground">
|
||||
<Checkbox
|
||||
checked={keysConfirmed}
|
||||
onCheckedChange={(v) => setKeysConfirmed(!!v)}
|
||||
/>
|
||||
I confirm that I've securely stored my admin key
|
||||
</CheckboxWithLabel>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
|
||||
<Button
|
||||
onClick={handleCloseModal}
|
||||
disabled={!keysConfirmed}
|
||||
variant={"primary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import type { Project } from "@/api/projects";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import CreateVaultAccountButton from "./create-vault-account.client";
|
||||
import ListAccessTokens from "./list-access-tokens.client";
|
||||
import RotateAdminKeyButton from "./rotate-admin-key.client";
|
||||
|
||||
export function KeyManagement({
|
||||
maskedAdminKey,
|
||||
project,
|
||||
}: { maskedAdminKey?: string; project: Project }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="flex flex-col px-6 pt-6">
|
||||
<h2 className="font-semibold text-xl tracking-tight">Vault</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Secure, non-custodial key management system for your server wallets.{" "}
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/vault"
|
||||
className="underline"
|
||||
>
|
||||
Learn more.
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
{!maskedAdminKey ? (
|
||||
<CreateVaultAccountAlert project={project} />
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
{maskedAdminKey ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="flex flex-col p-6">
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
Admin Key
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This key is used to create new server wallets and access tokens.
|
||||
<br /> We do not store this key. If you lose it, you can rotate
|
||||
it to create a new one. Doing so will invalidate all existing
|
||||
access tokens.
|
||||
</p>
|
||||
<div className="h-4" />
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="flex min-w-[300px] flex-row items-center gap-2 rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{maskedAdminKey}
|
||||
</p>
|
||||
</div>
|
||||
<RotateAdminKeyButton project={project} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ListAccessTokens project={project} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function CreateVaultAccountAlert(props: {
|
||||
project: Project;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="px-6">
|
||||
<Alert variant="info" className="bg-background">
|
||||
<AlertTitle className="flex items-center gap-2">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
What is Vault?
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-primary-foreground text-sm">
|
||||
Vault is thirdweb's non-custodial key management system for your
|
||||
server wallets that allows you to:
|
||||
<ul className="list-disc py-2 pl-4">
|
||||
<li>Create multiple server wallets.</li>
|
||||
<li>Create Vault access tokens.</li>
|
||||
<li>Sign transactions using a Vault access token.</li>
|
||||
</ul>
|
||||
Your keys are stored in a hardware enclave, and all requests are
|
||||
end-to-end encrypted.{" "}
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/vault"
|
||||
className="underline"
|
||||
>
|
||||
Learn more about Vault security model.
|
||||
</Link>
|
||||
<div className="h-2" />
|
||||
Creating server wallets and access tokens requires a Vault admin
|
||||
account. Create one below to get started.
|
||||
</AlertDescription>
|
||||
<div className="h-4" />
|
||||
</Alert>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end gap-4 border-border border-t px-6 pt-4 pb-4">
|
||||
<CreateVaultAccountButton project={props.project} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
"use client";
|
||||
import type { Project } from "@/api/projects";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import {} from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {} from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { listAccessTokens, revokeAccessToken } from "@thirdweb-dev/vault-sdk";
|
||||
import { Loader2Icon, LockIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { toDateTimeLocal } from "utils/date-utils";
|
||||
import { useTrack } from "../../../../../../../../../hooks/analytics/useTrack";
|
||||
import {
|
||||
SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE,
|
||||
createWalletAccessToken,
|
||||
initVaultClient,
|
||||
} from "../../lib/vault.client";
|
||||
|
||||
export default function ListAccessTokens(props: {
|
||||
project: Project;
|
||||
}) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [typedAdminKey, setTypedAdminKey] = useState("");
|
||||
const [adminKey, setAdminKey] = useState("");
|
||||
const [deletingTokenId, setDeletingTokenId] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const trackEvent = useTrack();
|
||||
// TODO allow passing permissions to the access token
|
||||
const createAccessTokenMutation = useMutation({
|
||||
mutationFn: async (args: { adminKey: string }) => {
|
||||
const vaultClient = await initVaultClient();
|
||||
|
||||
trackEvent({
|
||||
category: "engine-cloud",
|
||||
action: "create_access_token",
|
||||
});
|
||||
|
||||
const userAccessTokenRes = await createWalletAccessToken({
|
||||
project: props.project,
|
||||
adminKey: args.adminKey,
|
||||
vaultClient,
|
||||
});
|
||||
|
||||
if (!userAccessTokenRes.success) {
|
||||
throw new Error(
|
||||
`Failed to create access token: ${userAccessTokenRes.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
userAccessToken: userAccessTokenRes.data,
|
||||
};
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["list-access-tokens"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const revokeAccessTokenMutation = useMutation({
|
||||
mutationFn: async (args: { adminKey: string; accessTokenId: string }) => {
|
||||
setDeletingTokenId(args.accessTokenId);
|
||||
const vaultClient = await initVaultClient();
|
||||
|
||||
trackEvent({
|
||||
category: "engine-cloud",
|
||||
action: "revoke_access_token",
|
||||
});
|
||||
|
||||
const revokeAccessTokenRes = await revokeAccessToken({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
options: {
|
||||
id: args.accessTokenId,
|
||||
},
|
||||
auth: {
|
||||
adminKey: args.adminKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!revokeAccessTokenRes.success) {
|
||||
throw new Error(
|
||||
`Failed to revoke access token: ${revokeAccessTokenRes.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
setDeletingTokenId(null);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["list-access-tokens"],
|
||||
});
|
||||
setDeletingTokenId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const managementAccessToken = props.project.services.find(
|
||||
(s) => s.name === "engineCloud",
|
||||
)?.managementAccessToken;
|
||||
|
||||
const listAccessTokensQuery = useQuery({
|
||||
queryKey: [
|
||||
"list-access-tokens",
|
||||
maskSecret(managementAccessToken || ""),
|
||||
maskSecret(adminKey),
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!managementAccessToken) {
|
||||
throw new Error("Management access token not found");
|
||||
}
|
||||
const vaultClient = await initVaultClient();
|
||||
const listResult = await listAccessTokens({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: adminKey
|
||||
? {
|
||||
adminKey,
|
||||
}
|
||||
: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
});
|
||||
|
||||
if (!listResult.success) {
|
||||
throw new Error(
|
||||
`Failed to list access tokens: ${listResult.error.message}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
accessTokens: listResult.data.items
|
||||
.filter(
|
||||
(t) =>
|
||||
t.metadata?.purpose?.toString() !==
|
||||
SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE,
|
||||
)
|
||||
.filter((t) => !t.revokedAt && !t.isRotated),
|
||||
};
|
||||
// Return stub data for now
|
||||
},
|
||||
enabled: !!managementAccessToken,
|
||||
});
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="flex flex-col px-6 pt-6">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
Access Tokens
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Access tokens let you sign transactions using any of your server
|
||||
wallets.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!adminKey) {
|
||||
setModalOpen(true);
|
||||
} else {
|
||||
setAdminKey("");
|
||||
setTypedAdminKey("");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["list-access-tokens"],
|
||||
});
|
||||
}
|
||||
}}
|
||||
variant={"primary"}
|
||||
className="flex flex-row items-center gap-2"
|
||||
>
|
||||
<LockIcon className="h-4 w-4" />{" "}
|
||||
{adminKey ? "Lock Vault" : "Unlock Vault"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-4" />
|
||||
{listAccessTokensQuery.isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-10">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : listAccessTokensQuery.error ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-destructive-text text-sm">
|
||||
Failed to list access tokens. Check your admin key and try again.
|
||||
</p>
|
||||
</div>
|
||||
) : listAccessTokensQuery.data ? (
|
||||
<div>
|
||||
<div className="space-y-6 pt-0">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{listAccessTokensQuery.data.accessTokens.map((token) => (
|
||||
<div key={token.id} className="flex gap-2">
|
||||
<div className="flex max-w-full flex-1 flex-col justify-between gap-4 rounded-lg border border-border bg-background p-4 text-xs">
|
||||
<h4 className="font-bold">
|
||||
{token.metadata?.purpose || "Unnamed Access Token"}
|
||||
</h4>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{token.accessToken.includes("**") ? (
|
||||
<div className="flex flex-grow flex-row items-center gap-2 rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{token.accessToken}{" "}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
(unlock vault to reveal the full token)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<CopyTextButton
|
||||
textToCopy={token.accessToken}
|
||||
className="!h-auto min-w-0 flex-grow justify-between bg-background px-3 py-3 font-mono text-xs"
|
||||
textToShow={token.accessToken}
|
||||
copyIconPosition="right"
|
||||
tooltip="Copy Vault Access Token"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="px-3 py-3"
|
||||
onClick={() =>
|
||||
revokeAccessTokenMutation.mutate({
|
||||
adminKey,
|
||||
accessTokenId: token.id,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
deletingTokenId !== null ||
|
||||
revokeAccessTokenMutation.isPending ||
|
||||
!adminKey
|
||||
}
|
||||
>
|
||||
{deletingTokenId === token.id ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2Icon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{/* TODO (cloud): show policies and let you edit them */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Created on:{" "}
|
||||
{toDateTimeLocal(new Date(token.createdAt))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No access tokens found.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 bg-card py-4">
|
||||
<Button
|
||||
onClick={() => createAccessTokenMutation.mutate({ adminKey })}
|
||||
disabled={createAccessTokenMutation.isPending || !adminKey}
|
||||
variant={"primary"}
|
||||
>
|
||||
{createAccessTokenMutation.isPending ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
Creating new access token...
|
||||
</>
|
||||
) : (
|
||||
"Create new access token"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={modalOpen} onOpenChange={handleCloseModal} modal={true}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>Unlock Vault</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 px-6">
|
||||
<p className="flex items-center gap-2 text-sm text-warning-text">
|
||||
<LockIcon className="h-4 w-4" /> This action requires your
|
||||
Vault admin key.
|
||||
</p>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="sa_adm_ABCD_1234..."
|
||||
value={typedAdminKey}
|
||||
onChange={(e) => setTypedAdminKey(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setAdminKey(typedAdminKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setAdminKey("");
|
||||
setTypedAdminKey("");
|
||||
setModalOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => {
|
||||
setAdminKey(typedAdminKey);
|
||||
handleCloseModal();
|
||||
}}
|
||||
disabled={!typedAdminKey || listAccessTokensQuery.isLoading}
|
||||
>
|
||||
Unlock Vault
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function maskSecret(secret: string) {
|
||||
return `${secret.substring(0, 10)}...${secret.substring(secret.length - 10)}`;
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import type { Project } from "@/api/projects";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { rotateServiceAccount } from "@thirdweb-dev/vault-sdk";
|
||||
import {
|
||||
CheckIcon,
|
||||
CircleAlertIcon,
|
||||
DownloadIcon,
|
||||
Loader2Icon,
|
||||
RefreshCcwIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTrack } from "../../../../../../../../../hooks/analytics/useTrack";
|
||||
import {
|
||||
createManagementAccessToken,
|
||||
createWalletAccessToken,
|
||||
initVaultClient,
|
||||
maskSecret,
|
||||
} from "../../lib/vault.client";
|
||||
|
||||
export default function RotateAdminKeyButton(props: { project: Project }) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [keysConfirmed, setKeysConfirmed] = useState(false);
|
||||
const [keysDownloaded, setKeysDownloaded] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
const trackEvent = useTrack();
|
||||
|
||||
const rotateAdminKeyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
trackEvent({
|
||||
category: "engine-cloud",
|
||||
action: "rotate_admin_key",
|
||||
});
|
||||
|
||||
const vaultClient = await initVaultClient();
|
||||
const rotationCode = props.project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
)?.rotationCode;
|
||||
|
||||
if (!rotationCode) {
|
||||
throw new Error("Rotation code not found");
|
||||
}
|
||||
|
||||
const rotateServiceAccountRes = await rotateServiceAccount({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
rotationCode,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (rotateServiceAccountRes.error) {
|
||||
throw new Error(rotateServiceAccountRes.error.message);
|
||||
}
|
||||
|
||||
// need to recreate the management access token with the new admin key
|
||||
const managementAccessTokenPromise = createManagementAccessToken({
|
||||
project: props.project,
|
||||
adminKey: rotateServiceAccountRes.data.newAdminKey,
|
||||
rotationCode: rotateServiceAccountRes.data.newRotationCode,
|
||||
vaultClient,
|
||||
});
|
||||
|
||||
const userAccesTokenPromise = createWalletAccessToken({
|
||||
project: props.project,
|
||||
adminKey: rotateServiceAccountRes.data.newAdminKey,
|
||||
vaultClient,
|
||||
});
|
||||
|
||||
const [userAccessTokenRes, managementAccessTokenRes] = await Promise.all([
|
||||
userAccesTokenPromise,
|
||||
managementAccessTokenPromise,
|
||||
]);
|
||||
|
||||
if (!managementAccessTokenRes.success || !userAccessTokenRes.success) {
|
||||
throw new Error("Failed to create access token");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
adminKey: rotateServiceAccountRes.data.newAdminKey,
|
||||
userAccessToken: userAccessTokenRes.data,
|
||||
};
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownloadKeys = () => {
|
||||
if (!rotateAdminKeyMutation.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${rotateAdminKeyMutation.data.adminKey}\n\nVault Access Token:\n${rotateAdminKeyMutation.data.userAccessToken.accessToken}\n`;
|
||||
const blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
const filename = `${props.project.name}-vault-keys-rotated.txt`;
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link); // Required for Firefox
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`Keys downloaded as ${filename}`);
|
||||
setKeysDownloaded(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
if (!keysConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setModalOpen(false);
|
||||
setKeysConfirmed(false);
|
||||
setKeysDownloaded(false);
|
||||
// invalidate the page to force a reload
|
||||
rotateAdminKeyMutation.reset();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const isLoading = rotateAdminKeyMutation.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
className="h-auto gap-2 rounded-lg bg-background px-4 py-3"
|
||||
>
|
||||
{isLoading && <Loader2Icon className="size-4 animate-spin" />}
|
||||
{!isLoading && <RefreshCcwIcon className="size-4" />}
|
||||
Rotate Admin Key
|
||||
</Button>
|
||||
|
||||
<Dialog open={modalOpen} onOpenChange={handleCloseModal} modal={true}>
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0"
|
||||
dialogCloseClassName={cn(!keysConfirmed && "hidden")}
|
||||
>
|
||||
{rotateAdminKeyMutation.isPending ? (
|
||||
<>
|
||||
<DialogHeader className="p-6">
|
||||
<DialogTitle>Generating new keys...</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-10">
|
||||
<Spinner className="size-8" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
This may take a few seconds.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : rotateAdminKeyMutation.data ? (
|
||||
<div>
|
||||
<DialogHeader className="p-6">
|
||||
<DialogTitle>New Vault Keys</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 p-6 pt-0">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium text-sm">
|
||||
New Vault Admin Key
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CopyTextButton
|
||||
textToCopy={rotateAdminKeyMutation.data.adminKey}
|
||||
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
|
||||
textToShow={maskSecret(
|
||||
rotateAdminKeyMutation.data.adminKey,
|
||||
)}
|
||||
copyIconPosition="right"
|
||||
tooltip="Copy Admin Key"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
This key is used to create or revoke your access tokens.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium text-sm">
|
||||
New Vault Access Token
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2 ">
|
||||
<CopyTextButton
|
||||
textToCopy={
|
||||
rotateAdminKeyMutation.data.userAccessToken
|
||||
.accessToken
|
||||
}
|
||||
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
|
||||
textToShow={maskSecret(
|
||||
rotateAdminKeyMutation.data.userAccessToken
|
||||
.accessToken,
|
||||
)}
|
||||
copyIconPosition="right"
|
||||
tooltip="Copy Vault Access Token"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
This access token is used to sign transactions and
|
||||
messages from your backend. Can be revoked and recreated
|
||||
with your admin key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Secure your keys</AlertTitle>
|
||||
<AlertDescription>
|
||||
These keys will not be displayed again. Store them securely
|
||||
as they provide access to your server wallets.
|
||||
</AlertDescription>
|
||||
<div className="h-4" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={handleDownloadKeys}
|
||||
className="flex h-auto items-center gap-2 p-0 text-sm text-success-text"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
{keysDownloaded ? "Keys Downloaded" : "Download Keys"}
|
||||
</Button>
|
||||
{keysDownloaded && (
|
||||
<span className="text-success-text text-xs">
|
||||
<CheckIcon className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-4" />
|
||||
<CheckboxWithLabel className="text-foreground">
|
||||
<Checkbox
|
||||
checked={keysConfirmed}
|
||||
onCheckedChange={(v) => setKeysConfirmed(!!v)}
|
||||
/>
|
||||
I confirm that I've securely stored these keys
|
||||
</CheckboxWithLabel>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
|
||||
<Button
|
||||
onClick={handleCloseModal}
|
||||
disabled={!keysConfirmed}
|
||||
variant="primary"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>Rotate your Vault admin key</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action will generate a new Vault admin key and rotation
|
||||
code.{" "}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 px-6">
|
||||
<p className="text-md text-primary-foreground">
|
||||
Revoke your current keys and generates new ones.
|
||||
</p>
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>Important</AlertTitle>
|
||||
<AlertDescription>
|
||||
This action will invalidate your current admin key and all
|
||||
existing access tokens. You will need to update your
|
||||
backend to use these new access tokens.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setModalOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => rotateAdminKeyMutation.mutate()}
|
||||
disabled={rotateAdminKeyMutation.isPending}
|
||||
>
|
||||
{rotateAdminKeyMutation.isPending ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
Rotating...
|
||||
</>
|
||||
) : (
|
||||
"Rotate Admin Key"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { getProject } from "@/api/projects";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "../../../../../../api/lib/getAuthToken";
|
||||
import { KeyManagement } from "./components/key-management";
|
||||
|
||||
export default async function VaultPage(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
}) {
|
||||
const { team_slug, project_slug } = await props.params;
|
||||
const [authToken, project] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getProject(team_slug, project_slug),
|
||||
]);
|
||||
|
||||
if (!project || !authToken) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const projectEngineCloudService = project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
|
||||
const maskedAdminKey = projectEngineCloudService?.maskedAdminKey;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<KeyManagement
|
||||
maskedAdminKey={maskedAdminKey ?? undefined}
|
||||
project={project}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
@@ -6,6 +6,7 @@ const meta = {
|
||||
component: EngineFooterCard,
|
||||
args: {
|
||||
team_slug: "demo-team",
|
||||
project_slug: "demo-project",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
+10
-6
@@ -27,8 +27,8 @@ export function ImportEngineLink(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function EngineInfoSection(props: { team_slug: string }) {
|
||||
const engineLinkPrefix = `/team/${props.team_slug}/~/engine`;
|
||||
function EngineInfoSection(props: { team_slug: string; project_slug: string }) {
|
||||
const engineLinkPrefix = `/team/${props.team_slug}/${props.project_slug}/engine/dedicated`;
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
@@ -84,7 +84,7 @@ function CloudHostedEngineSection(props: {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<h3 className="mb-0.5 font-semibold text-lg tracking-tight">
|
||||
Get Cloud Hosted Engine
|
||||
Get Managed Engine
|
||||
</h3>
|
||||
|
||||
{props.teamPlan !== "pro" ? (
|
||||
@@ -98,7 +98,7 @@ function CloudHostedEngineSection(props: {
|
||||
<UnderlineLink href="/pricing" target="_blank">
|
||||
Scale
|
||||
</UnderlineLink>{" "}
|
||||
to get a cloud hosted Engine
|
||||
to get a managed Engine instance
|
||||
</p>
|
||||
|
||||
<div className="h-5" />
|
||||
@@ -131,7 +131,7 @@ function CloudHostedEngineSection(props: {
|
||||
) : (
|
||||
<div>
|
||||
<p className="mb-4 text-muted-foreground text-sm">
|
||||
Contact us to get a cloud hosted engine for your team
|
||||
Contact us to get a managed engine for your team
|
||||
</p>
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<Link href={PRO_CONTACT_US_URL} target="_blank">
|
||||
@@ -148,6 +148,7 @@ function CloudHostedEngineSection(props: {
|
||||
export function EngineFooterCard(props: {
|
||||
teamPlan: Team["billingPlan"];
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative rounded-lg border p-6">
|
||||
@@ -161,7 +162,10 @@ export function EngineFooterCard(props: {
|
||||
</>
|
||||
)}
|
||||
|
||||
<EngineInfoSection team_slug={props.team_slug} />
|
||||
<EngineInfoSection
|
||||
team_slug={props.team_slug}
|
||||
project_slug={props.project_slug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+4
-1
@@ -55,6 +55,7 @@ async function importEngine({
|
||||
export function EngineImportCard(props: {
|
||||
prefillImportUrl: string | undefined;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
|
||||
@@ -63,7 +64,9 @@ export function EngineImportCard(props: {
|
||||
prefillImportUrl={props.prefillImportUrl}
|
||||
importEngine={async (params) => {
|
||||
await importEngine({ ...params, teamIdOrSlug: props.teamSlug });
|
||||
router.push(`/team/${props.teamSlug}/~/engine`);
|
||||
router.push(
|
||||
`/team/${props.teamSlug}/${props.projectSlug}/engine/dedicated`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
import { EngineImportCard } from "./EngineImportPage";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string }>;
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
searchParams: Promise<{
|
||||
importUrl?: string | string[];
|
||||
}>;
|
||||
@@ -21,6 +21,7 @@ export default async function Page(props: {
|
||||
<EngineImportCard
|
||||
prefillImportUrl={importUrl ? decodeURIComponent(importUrl) : undefined}
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import type { SidebarLink } from "@/components/blocks/Sidebar";
|
||||
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DatabaseIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { EngineIcon } from "../../../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
|
||||
import { ImportEngineLink } from "./_components";
|
||||
|
||||
export default async function Layout(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
}>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const linkPrefix = `/team/${params.team_slug}/${params.project_slug}/engine/dedicated`;
|
||||
const sidebarLinks: SidebarLink[] = [
|
||||
{
|
||||
label: "Engine Instances",
|
||||
href: `${linkPrefix}`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
label: "Import Engine",
|
||||
href: `${linkPrefix}/import`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container flex max-w-7xl grow flex-col">
|
||||
{/* header */}
|
||||
<header className="border-border border-b py-10">
|
||||
<div className="container flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="flex items-center gap-2 font-semibold text-3xl tracking-tight">
|
||||
Engine{" "}
|
||||
<Badge
|
||||
variant="warning"
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<DatabaseIcon className="size-4" /> Dedicated
|
||||
</Badge>
|
||||
</h1>
|
||||
<p className="flex flex-col text-muted-foreground text-sm">
|
||||
Manage your deployed Engine instances
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ImportEngineLink
|
||||
label="Import Engine"
|
||||
engineLinkPrefix={linkPrefix}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6" />
|
||||
<div className="container">
|
||||
<EngineLegacyBannerUI
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* sidebar layout */}
|
||||
<SidebarLayout sidebarLinks={sidebarLinks}>
|
||||
{props.children}
|
||||
</SidebarLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EngineLegacyBannerUI(props: {
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<Alert variant="info">
|
||||
<EngineIcon className="size-5" />
|
||||
<AlertTitle>Engine Cloud (Beta)</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="h-2" />
|
||||
<p className="text-primary-foreground text-sm">
|
||||
Try Engine Cloud (Beta) - now included for free in every thirdweb
|
||||
project.
|
||||
</p>
|
||||
<div className="h-2" />
|
||||
<ul className="list-disc pl-4 text-muted-foreground text-sm">
|
||||
<li>No recurring monthly cost, pay-per-request model</li>
|
||||
<li>Powered by Vault: our new TEE based key management system</li>
|
||||
<li>Improved performance and simplified transaction API</li>
|
||||
</ul>
|
||||
<div className="h-4" />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link href={"https://portal.thirdweb.com/engine/v3"} target="_blank">
|
||||
<Button variant="outline">Learn More</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/team/${props.teamSlug}/${props.projectSlug}/engine/cloud`}
|
||||
>
|
||||
<Button variant={"primary"} className="flex items-center gap-2">
|
||||
<EngineIcon className="size-4" /> Try Engine Cloud
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
+23
-10
@@ -83,6 +83,7 @@ type RemovedEngineFromDashboard = (
|
||||
|
||||
export function EngineInstancesTable(props: {
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
instances: EngineInstance[];
|
||||
engineLinkPrefix: string;
|
||||
teamPlan: Team["billingPlan"];
|
||||
@@ -95,6 +96,7 @@ export function EngineInstancesTable(props: {
|
||||
instances={props.instances}
|
||||
engineLinkPrefix={props.engineLinkPrefix}
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
deleteCloudHostedEngine={async (params) => {
|
||||
await deleteCloudHostedEngine(params);
|
||||
router.refresh();
|
||||
@@ -119,6 +121,7 @@ export function EngineInstancesTableUI(props: {
|
||||
removeEngineFromDashboard: RemovedEngineFromDashboard;
|
||||
teamPlan: Team["billingPlan"];
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
@@ -127,14 +130,19 @@ export function EngineInstancesTableUI(props: {
|
||||
</h2>
|
||||
|
||||
{props.instances.length === 0 ? (
|
||||
<EmptyEngineState teamPlan={props.teamPlan} teamSlug={props.teamSlug} />
|
||||
<EmptyEngineState
|
||||
teamPlan={props.teamPlan}
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
/>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Engine Instance</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
<TableHead className="w-full">Engine Instance</TableHead>
|
||||
<TableHead className="w-[100px]">Version</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -177,7 +185,7 @@ function EngineInstanceRow(props: {
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex-grow space-y-0.5">
|
||||
<InstanceNameLink
|
||||
instance={instance}
|
||||
engineLinkPrefix={engineLinkPrefix}
|
||||
@@ -191,6 +199,9 @@ function EngineInstanceRow(props: {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="warning">v2</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="relative z-10 w-[50px]">
|
||||
<EngineActionsDropdown
|
||||
instance={instance}
|
||||
@@ -806,6 +817,7 @@ function DeleteEngineSubscriptionModalContent(props: {
|
||||
function EmptyEngineState(props: {
|
||||
teamPlan: Team["billingPlan"];
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const [selectedTab, setSelectedTab] = useState<
|
||||
"self-hosted" | "cloud-hosted"
|
||||
@@ -834,7 +846,7 @@ function EmptyEngineState(props: {
|
||||
className={cn(selectedTab === "cloud-hosted" && "bg-accent")}
|
||||
onClick={() => setSelectedTab("cloud-hosted")}
|
||||
>
|
||||
Cloud-hosted
|
||||
Managed
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -866,7 +878,9 @@ function EmptyEngineState(props: {
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/team/${props.teamSlug}/~/engine/import`}>
|
||||
<Link
|
||||
href={`/team/${props.teamSlug}/${props.projectSlug}/engine/dedicated/import`}
|
||||
>
|
||||
Import self-hosted Engine
|
||||
<ArrowRightIcon size={16} />
|
||||
</Link>
|
||||
@@ -878,21 +892,20 @@ function EmptyEngineState(props: {
|
||||
{selectedTab === "cloud-hosted" && (
|
||||
<div className="flex flex-col text-center">
|
||||
<h3 className="mb-0.5 font-semibold text-base">
|
||||
Cloud-Hosted Engine
|
||||
Managed Engine
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{props.teamPlan !== "pro" ? (
|
||||
<>
|
||||
Upgrade your team plan to Accelerate or Scale to get a
|
||||
fully managed cloud-hosted engine instance.{" "}
|
||||
fully managed engine instance.{" "}
|
||||
<UnderlineLink href="/pricing" target="_blank">
|
||||
View pricing
|
||||
</UnderlineLink>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Contact us to add a cloud-hosted engine instance to your
|
||||
team
|
||||
Contact us to add a managed engine instance to your team
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
+8
-2
@@ -5,22 +5,28 @@ import { EngineInstancesTable } from "./engine-instances-table";
|
||||
|
||||
export const EngineInstancesList = (props: {
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
instances: EngineInstance[];
|
||||
teamPlan: Team["billingPlan"];
|
||||
}) => {
|
||||
const engineLinkPrefix = `/team/${props.team_slug}/~/engine`;
|
||||
const engineLinkPrefix = `/team/${props.team_slug}/${props.project_slug}/engine/dedicated`;
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<EngineInstancesTable
|
||||
teamSlug={props.team_slug}
|
||||
projectSlug={props.project_slug}
|
||||
instances={props.instances}
|
||||
engineLinkPrefix={engineLinkPrefix}
|
||||
teamPlan={props.teamPlan}
|
||||
/>
|
||||
|
||||
<div className="h-40" />
|
||||
<EngineFooterCard teamPlan={props.teamPlan} team_slug={props.team_slug} />
|
||||
<EngineFooterCard
|
||||
teamPlan={props.teamPlan}
|
||||
team_slug={props.team_slug}
|
||||
project_slug={props.project_slug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+6
-2
@@ -8,6 +8,7 @@ import { EngineInstancesList } from "./overview/engine-list";
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
importUrl?: string;
|
||||
@@ -20,7 +21,7 @@ export default async function Page(props: {
|
||||
|
||||
if (searchParams.importUrl) {
|
||||
redirect(
|
||||
`/team/${params.team_slug}/~/engine/import?importUrl=${searchParams.importUrl}`,
|
||||
`/team/${params.team_slug}/${params.project_slug}/engine/dedicated/import?importUrl=${searchParams.importUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +31,9 @@ export default async function Page(props: {
|
||||
]);
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(`/team/${params.team_slug}/~/engine`);
|
||||
loginRedirect(
|
||||
`/team/${params.team_slug}/${params.project_slug}/engine/dedicated`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
@@ -45,6 +48,7 @@ export default async function Page(props: {
|
||||
return (
|
||||
<EngineInstancesList
|
||||
team_slug={params.team_slug}
|
||||
project_slug={params.project_slug}
|
||||
instances={res.data || []}
|
||||
teamPlan={team.billingPlan}
|
||||
/>
|
||||
+2
-1
@@ -51,9 +51,10 @@ const sidebarLinkMeta: Array<{ pathId: string; label: string }> = [
|
||||
export function EngineSidebarLayout(props: {
|
||||
engineId: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const rootPath = `/team/${props.teamSlug}/~/engine`;
|
||||
const rootPath = `/team/${props.teamSlug}/${props.projectSlug}/engine/dedicated`;
|
||||
|
||||
const links: SidebarLink[] = sidebarLinkMeta.map((linkMeta) => {
|
||||
return {
|
||||
+3
-2
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type React from "react";
|
||||
import GenericLoadingPage from "../../../../ecosystem/loading";
|
||||
import { getEngineAccessPermission } from "../../../_utils/getEngineAccessPermission";
|
||||
import { EngineErrorPage } from "./EngineErrorPage";
|
||||
|
||||
@@ -13,10 +13,11 @@ export function EnsureEnginePermission(props: {
|
||||
authToken: string;
|
||||
children: React.ReactNode;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
instance: EngineInstance;
|
||||
}) {
|
||||
const { instance } = props;
|
||||
const rootPath = `/team/${props.teamSlug}/~/engine`;
|
||||
const rootPath = `/team/${props.teamSlug}/${props.projectSlug}/engine/dedicated`;
|
||||
|
||||
const permissionQuery = useQuery({
|
||||
queryKey: [
|
||||
+1
@@ -7,6 +7,7 @@ export default async function Page(props: EngineInstancePageProps) {
|
||||
const { instance, authToken } = await engineInstancePageHandler({
|
||||
engineId: params.engineId,
|
||||
teamSlug: params.team_slug,
|
||||
projectSlug: params.project_slug,
|
||||
});
|
||||
|
||||
return (
|
||||
+1
@@ -7,6 +7,7 @@ export default async function Page(props: EngineInstancePageProps) {
|
||||
const { instance, authToken } = await engineInstancePageHandler({
|
||||
engineId: params.engineId,
|
||||
teamSlug: params.team_slug,
|
||||
projectSlug: params.project_slug,
|
||||
});
|
||||
|
||||
return <EngineAdmins instanceUrl={instance.url} authToken={authToken} />;
|
||||
+1
-1
@@ -239,7 +239,7 @@ function CreateAlertButton(props: {
|
||||
<ToolTipLabel
|
||||
label={
|
||||
props.alertRules.length === 0
|
||||
? "This feature is only available for cloud-hosted Engines running v2.0.10 or later."
|
||||
? "This feature is only available for managed Engines running v2.0.10 or later."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
+1
@@ -7,6 +7,7 @@ export default async function Page(props: EngineInstancePageProps) {
|
||||
const { instance } = await engineInstancePageHandler({
|
||||
engineId: params.engineId,
|
||||
teamSlug: params.team_slug,
|
||||
projectSlug: params.project_slug,
|
||||
});
|
||||
|
||||
return (
|
||||
+8
-1
@@ -9,12 +9,14 @@ import { EngineSystem } from "./system";
|
||||
interface EngineConfigurationProps {
|
||||
instance: EngineInstance;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
export const EngineConfiguration: React.FC<EngineConfigurationProps> = ({
|
||||
instance,
|
||||
teamSlug,
|
||||
projectSlug,
|
||||
authToken,
|
||||
}) => {
|
||||
return (
|
||||
@@ -22,6 +24,7 @@ export const EngineConfiguration: React.FC<EngineConfigurationProps> = ({
|
||||
<EngineWalletConfig
|
||||
instance={instance}
|
||||
teamSlug={teamSlug}
|
||||
projectSlug={projectSlug}
|
||||
authToken={authToken}
|
||||
/>
|
||||
<EngineCorsConfig instanceUrl={instance.url} authToken={authToken} />
|
||||
@@ -29,7 +32,11 @@ export const EngineConfiguration: React.FC<EngineConfigurationProps> = ({
|
||||
instanceUrl={instance.url}
|
||||
authToken={authToken}
|
||||
/>
|
||||
<EngineSystem instance={instance} teamIdOrSlug={teamSlug} />
|
||||
<EngineSystem
|
||||
instance={instance}
|
||||
teamIdOrSlug={teamSlug}
|
||||
projectSlug={projectSlug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+3
-1
@@ -23,12 +23,14 @@ import { LocalConfig } from "./local-config";
|
||||
interface EngineWalletConfigProps {
|
||||
instance: EngineInstance;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
export const EngineWalletConfig: React.FC<EngineWalletConfigProps> = ({
|
||||
instance,
|
||||
teamSlug,
|
||||
projectSlug,
|
||||
authToken,
|
||||
}) => {
|
||||
const { data: walletConfig } = useEngineWalletConfig({
|
||||
@@ -72,7 +74,7 @@ export const EngineWalletConfig: React.FC<EngineWalletConfigProps> = ({
|
||||
<p className="text-muted-foreground">
|
||||
Create backend wallets on the{" "}
|
||||
<Link
|
||||
href={`/team/${teamSlug}/~/engine/${instance.id}`}
|
||||
href={`/team/${teamSlug}/${projectSlug}/engine/dedicated/${instance.id}`}
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
Overview
|
||||
+7
-1
@@ -7,14 +7,20 @@ import {
|
||||
interface EngineSystemProps {
|
||||
instance: EngineInstance;
|
||||
teamIdOrSlug: string;
|
||||
projectSlug: string;
|
||||
}
|
||||
|
||||
export const EngineSystem: React.FC<EngineSystemProps> = ({
|
||||
instance,
|
||||
teamIdOrSlug,
|
||||
projectSlug,
|
||||
}) => {
|
||||
const healthQuery = useEngineSystemHealth(instance.url);
|
||||
const metricsQuery = useEngineSystemMetrics(instance.id, teamIdOrSlug);
|
||||
const metricsQuery = useEngineSystemMetrics(
|
||||
instance.id,
|
||||
teamIdOrSlug,
|
||||
projectSlug,
|
||||
);
|
||||
if (!healthQuery.data) {
|
||||
return null;
|
||||
}
|
||||
+2
@@ -7,12 +7,14 @@ export default async function Page(props: EngineInstancePageProps) {
|
||||
const { instance, authToken } = await engineInstancePageHandler({
|
||||
engineId: params.engineId,
|
||||
teamSlug: params.team_slug,
|
||||
projectSlug: params.project_slug,
|
||||
});
|
||||
|
||||
return (
|
||||
<EngineConfiguration
|
||||
instance={instance}
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
authToken={authToken}
|
||||
/>
|
||||
);
|
||||
+1
@@ -7,6 +7,7 @@ export default async function Page(props: EngineInstancePageProps) {
|
||||
const { instance, authToken } = await engineInstancePageHandler({
|
||||
engineId: params.engineId,
|
||||
teamSlug: params.team_slug,
|
||||
projectSlug: params.project_slug,
|
||||
});
|
||||
|
||||
return (
|
||||
+1
@@ -7,6 +7,7 @@ export default async function Page(props: EngineInstancePageProps) {
|
||||
const { instance, authToken } = await engineInstancePageHandler({
|
||||
engineId: params.engineId,
|
||||
teamSlug: params.team_slug,
|
||||
projectSlug: params.project_slug,
|
||||
});
|
||||
|
||||
return <EngineExplorer instanceUrl={instance.url} authToken={authToken} />;
|
||||
+8
-2
@@ -20,6 +20,7 @@ import { EngineVersionBadge } from "./_components/version";
|
||||
export default async function Layout(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
engineId: string;
|
||||
}>;
|
||||
children: React.ReactNode;
|
||||
@@ -29,7 +30,9 @@ export default async function Layout(props: {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(`/team/${params.team_slug}/~/engine/${params.engineId}`);
|
||||
loginRedirect(
|
||||
`/team/${params.team_slug}/${params.project_slug}/engine/dedicated/${params.engineId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const instance = await getEngineInstance({
|
||||
@@ -39,13 +42,14 @@ export default async function Layout(props: {
|
||||
accountId: account.id,
|
||||
});
|
||||
|
||||
const engineRootLayoutPath = `/team/${params.team_slug}/~/engine`;
|
||||
const engineRootLayoutPath = `/team/${params.team_slug}/${params.project_slug}/engine/dedicated`;
|
||||
|
||||
if (!instance) {
|
||||
return (
|
||||
<EngineSidebarLayout
|
||||
engineId={params.engineId}
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
>
|
||||
<EngineErrorPage rootPath={engineRootLayoutPath}>
|
||||
Engine Instance Not Found
|
||||
@@ -66,9 +70,11 @@ export default async function Layout(props: {
|
||||
<EngineSidebarLayout
|
||||
engineId={params.engineId}
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
>
|
||||
<EnsureEnginePermission
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
accountId={account.id}
|
||||
authToken={authToken}
|
||||
engineId={params.engineId}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user