diff --git a/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx b/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx index 0054ac769cc..7f4bee84f78 100644 --- a/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx +++ b/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx @@ -30,7 +30,7 @@ import { } from "@/components/ui/sidebar"; import { cn } from "@/lib/utils"; -type ShadcnSidebarBaseLink = { +export type ShadcnSidebarBaseLink = { href: string; label: React.ReactNode; exactMatch?: boolean; @@ -38,11 +38,11 @@ type ShadcnSidebarBaseLink = { isActive?: (pathname: string) => boolean; }; -type ShadcnSidebarLink = +export type ShadcnSidebarLink = | ShadcnSidebarBaseLink | { group: string; - links: ShadcnSidebarBaseLink[]; + links: ShadcnSidebarLink[]; } | { separator: true; @@ -97,10 +97,7 @@ export function FullWidthSidebarLayout(props: { function MobileSidebarTrigger(props: { links: ShadcnSidebarLink[] }) { const activeLink = useActiveShadcnSidebarLink(props.links); - const parentSubNav = props.links.find( - (link) => - "subMenu" in link && link.links.some((l) => l.href === activeLink?.href), - ); + const parentSubNav = findParentSubmenu(props.links, activeLink?.href); return (
@@ -109,7 +106,7 @@ function MobileSidebarTrigger(props: { links: ShadcnSidebarLink[] }) { className="h-4 bg-muted-foreground/50" orientation="vertical" /> - {parentSubNav && "subMenu" in parentSubNav && ( + {parentSubNav && ( <> {parentSubNav.subMenu.label} @@ -131,24 +128,65 @@ function useActiveShadcnSidebarLink(links: ShadcnSidebarLink[]) { return pathname?.startsWith(link.href); } - for (const link of links) { - if ("links" in link) { - for (const subLink of link.links) { - if (isActive(subLink)) { - return subLink; + function walk( + navLinks: ShadcnSidebarLink[], + ): ShadcnSidebarBaseLink | undefined { + for (const link of navLinks) { + if ("subMenu" in link) { + for (const subLink of link.links) { + if (isActive(subLink)) { + return subLink; + } + } + } else if ("href" in link) { + if (isActive(link)) { + return link; } } - } else if ("href" in link) { - if (isActive(link)) { - return link; + + if ("links" in link && !("subMenu" in link)) { + const nested = walk(link.links); + if (nested) { + return nested; + } } } + + return undefined; } + + return walk(links); }, [links, pathname]); return activeLink; } +function findParentSubmenu( + links: ShadcnSidebarLink[], + activeHref: string | undefined, +): Extract | undefined { + if (!activeHref) { + return undefined; + } + + for (const link of links) { + if ("subMenu" in link) { + if (link.links.some((subLink) => subLink.href === activeHref)) { + return link; + } + } + + if ("links" in link && !("subMenu" in link)) { + const nested = findParentSubmenu(link.links, activeHref); + if (nested) { + return nested; + } + } + } + + return undefined; +} + function useIsSubnavActive(links: ShadcnSidebarBaseLink[]) { const pathname = usePathname(); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/account-abstraction/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/account-abstraction/page.tsx index 6ca23d1d256..c38b2ebe90b 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/account-abstraction/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/account-abstraction/page.tsx @@ -1,163 +1,11 @@ -import type { Metadata } from "next"; -import { notFound, redirect } from "next/navigation"; -import type { SearchParams } from "nuqs/server"; -import { getUserOpUsage } from "@/api/analytics"; -import { getAuthToken } from "@/api/auth-token"; -import { getProject } from "@/api/project/projects"; -import { getTeamBySlug } from "@/api/team/get-team"; -import { - getLastNDaysRange, - type Range, -} from "@/components/analytics/date-range-selector"; -import { ProjectPage } from "@/components/blocks/project-page/project-page"; -import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { SmartAccountIcon } from "@/icons/SmartAccountIcon"; -import { getAbsoluteUrl } from "@/utils/vercel"; -import { AccountAbstractionSummary } from "./AccountAbstractionAnalytics/AccountAbstractionSummary"; -import { SmartWalletsBillingAlert } from "./Alerts"; -import { AccountAbstractionAnalytics } from "./aa-analytics"; -import { searchParamLoader } from "./search-params"; - -interface PageParams { - team_slug: string; - project_slug: string; -} +import { redirect } from "next/navigation"; +// Redirect old Account Abstraction page to new Sponsored Gas Overview export default async function Page(props: { - params: Promise; - searchParams: Promise; - children: React.ReactNode; + params: Promise<{ team_slug: string; project_slug: string }>; }) { - const [params, searchParams, authToken] = await Promise.all([ - props.params, - searchParamLoader(props.searchParams), - getAuthToken(), - ]); - - if (!authToken) { - notFound(); - } - - const [team, project] = await Promise.all([ - getTeamBySlug(params.team_slug), - getProject(params.team_slug, params.project_slug), - ]); - - if (!team) { - redirect("/team"); - } - - if (!project) { - redirect(`/team/${params.team_slug}`); - } - - const interval = searchParams.interval ?? "week"; - const rangeType = searchParams.range || "last-120"; - - const range: Range = { - from: - rangeType === "custom" - ? searchParams.from - : getLastNDaysRange(rangeType).from, - to: - rangeType === "custom" - ? searchParams.to - : getLastNDaysRange(rangeType).to, - type: rangeType, - }; - - const userOpStats = await getUserOpUsage( - { - from: range.from, - period: interval, - projectId: project.id, - teamId: project.teamId, - to: range.to, - }, - authToken, - ); - - const client = getClientThirdwebClient({ - jwt: authToken, - teamId: project.teamId, - }); - - const isBundlerServiceEnabled = !!project.services.find( - (s) => s.name === "bundler", - ); - - const hasSmartWalletsWithoutBilling = - isBundlerServiceEnabled && - team.billingStatus !== "validPayment" && - team.billingStatus !== "pastDue"; - - return ( - - {hasSmartWalletsWithoutBilling && ( - <> - -
- - )} -
- - - -
- + const params = await props.params; + redirect( + `/team/${params.team_slug}/${params.project_slug}/wallets/sponsored-gas/overview`, ); } - -const seo = { - desc: "Add account abstraction to your web3 app & unlock powerful features for seamless onboarding, customizable transactions, & maximum security. Get started.", - title: "The Complete Account Abstraction Toolkit | thirdweb", -}; - -export const metadata: Metadata = { - description: seo.desc, - openGraph: { - description: seo.desc, - images: [ - { - alt: seo.title, - height: 630, - url: `${getAbsoluteUrl()}/assets/og-image/dashboard-wallets-smart-wallet.png`, - width: 1200, - }, - ], - title: seo.title, - }, - title: seo.title, -}; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx index aff37b655fe..e702a9a0aff 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx @@ -1,23 +1,22 @@ "use client"; import { - ArrowLeftRightIcon, BookTextIcon, BoxIcon, DatabaseIcon, HomeIcon, - LockIcon, RssIcon, Settings2Icon, WebhookIcon, } from "lucide-react"; -import { FullWidthSidebarLayout } from "@/components/blocks/full-width-sidebar-layout"; -import { Badge } from "@/components/ui/badge"; +import { + FullWidthSidebarLayout, + type ShadcnSidebarLink, +} from "@/components/blocks/full-width-sidebar-layout"; import { BridgeIcon } from "@/icons/BridgeIcon"; import { ContractIcon } from "@/icons/ContractIcon"; import { InsightIcon } from "@/icons/InsightIcon"; import { NebulaIcon } from "@/icons/NebulaIcon"; import { PayIcon } from "@/icons/PayIcon"; -import { SmartAccountIcon } from "@/icons/SmartAccountIcon"; import { TokenIcon } from "@/icons/TokenIcon"; import { WalletProductIcon } from "@/icons/WalletProductIcon"; @@ -25,136 +24,136 @@ export function ProjectSidebarLayout(props: { layoutPath: string; children: React.ReactNode; }) { - return ( - - Transactions New - - ), - }, - { - href: `${props.layoutPath}/contracts`, - icon: ContractIcon, - label: "Contracts", - }, - { - href: `${props.layoutPath}/ai`, - icon: NebulaIcon, - label: "AI", - }, - ], - }, + const contentSidebarLinks = [ + { + exactMatch: true, + href: props.layoutPath, + icon: HomeIcon, + label: "Overview", + }, + { + separator: true, + }, + { + group: "Build", + links: [ { - separator: true, - }, - { - group: "Monetize", + subMenu: { + icon: WalletProductIcon, + label: "Wallets", + }, links: [ { - href: `${props.layoutPath}/payments`, - icon: PayIcon, - label: "Payments", + href: `${props.layoutPath}/wallets/user-wallets`, + label: "User Wallets", }, { - href: `${props.layoutPath}/bridge`, - icon: BridgeIcon, - label: "Bridge", + href: `${props.layoutPath}/wallets/server-wallets`, + label: "Server Wallets", }, { - href: `${props.layoutPath}/tokens`, - icon: TokenIcon, - label: "Tokens", + href: `${props.layoutPath}/wallets/sponsored-gas`, + label: "Sponsored Gas", }, ], }, { - separator: true, + href: `${props.layoutPath}/contracts`, + icon: ContractIcon, + label: "Contracts", }, { - group: "Scale", - links: [ - { - href: `${props.layoutPath}/insight`, - icon: InsightIcon, - label: "Insight", - }, - { - href: `${props.layoutPath}/account-abstraction`, - icon: SmartAccountIcon, - label: "Account Abstraction", - }, - { - href: `${props.layoutPath}/rpc`, - icon: RssIcon, - label: "RPC", - }, - { - href: `${props.layoutPath}/vault`, - icon: LockIcon, - label: "Vault", - }, - // linkely want to move this to `team` level eventually - { - href: `${props.layoutPath}/engine`, - icon: DatabaseIcon, - label: "Engine", - }, - ], + href: `${props.layoutPath}/ai`, + icon: NebulaIcon, + label: "AI", }, - ]} - footerSidebarLinks={[ + ], + }, + { + separator: true, + }, + { + group: "Monetize", + links: [ { - separator: true, + href: `${props.layoutPath}/payments`, + icon: PayIcon, + label: "Payments", }, { - href: `${props.layoutPath}/webhooks/contracts`, - icon: WebhookIcon, - isActive: (pathname) => { - return pathname.startsWith(`${props.layoutPath}/webhooks`); - }, - label: "Webhooks", + href: `${props.layoutPath}/bridge`, + icon: BridgeIcon, + label: "Bridge", }, { - href: `${props.layoutPath}/settings`, - icon: Settings2Icon, - label: "Project Settings", + href: `${props.layoutPath}/tokens`, + icon: TokenIcon, + label: "Tokens", }, + ], + }, + { + separator: true, + }, + { + group: "Scale", + links: [ { - separator: true, + href: `${props.layoutPath}/insight`, + icon: InsightIcon, + label: "Insight", }, { - href: "https://portal.thirdweb.com", - icon: BookTextIcon, - label: "Documentation", + href: `${props.layoutPath}/rpc`, + icon: RssIcon, + label: "RPC", }, + // linkely want to move this to `team` level eventually { - href: "https://playground.thirdweb.com/connect/sign-in/button", - icon: BoxIcon, - label: "Playground", + href: `${props.layoutPath}/engine`, + icon: DatabaseIcon, + label: "Engine", }, - ]} + ], + }, + ] satisfies ShadcnSidebarLink[]; + + const footerSidebarLinks = [ + { + separator: true, + }, + { + href: `${props.layoutPath}/webhooks/contracts`, + icon: WebhookIcon, + isActive: (pathname) => { + return pathname.startsWith(`${props.layoutPath}/webhooks`); + }, + label: "Webhooks", + }, + { + href: `${props.layoutPath}/settings`, + icon: Settings2Icon, + label: "Project Settings", + }, + { + separator: true, + }, + { + href: "https://portal.thirdweb.com", + icon: BookTextIcon, + label: "Documentation", + }, + { + href: "https://playground.thirdweb.com/connect/sign-in/button", + icon: BoxIcon, + label: "Playground", + }, + ] satisfies ShadcnSidebarLink[]; + + return ( + {props.children} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/account-abstraction/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/account-abstraction/page.tsx index 8764b31bfed..76ee5ab25e9 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/account-abstraction/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/account-abstraction/page.tsx @@ -1,95 +1,11 @@ -import { CircleAlertIcon } from "lucide-react"; import { redirect } from "next/navigation"; -import { getAuthToken } from "@/api/auth-token"; -import { getProject } from "@/api/project/projects"; -import { getTeamBySlug } from "@/api/team/get-team"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { UnderlineLink } from "@/components/ui/UnderlineLink"; -import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { getValidTeamPlan } from "@/utils/getValidTeamPlan"; -import { loginRedirect } from "@/utils/redirects"; -import { DefaultFactoriesSection } from "../../account-abstraction/factories/AccountFactories"; -import { YourFactoriesSection } from "../../account-abstraction/factories/AccountFactories/your-factories"; -import { AccountAbstractionSettingsPage } from "../../account-abstraction/settings/SponsorshipPolicies"; -import { ProjectSettingsBreadcrumb } from "../_components/project-settings-breadcrumb"; +// Redirect old settings/account-abstraction page to new Sponsored Gas Configuration export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string }>; }) { const { team_slug, project_slug } = await props.params; - - const [team, project, authToken] = await Promise.all([ - getTeamBySlug(team_slug), - getProject(team_slug, project_slug), - - getAuthToken(), - ]); - - if (!authToken) { - loginRedirect(`/team/${team_slug}/settings/account-abstraction`); - } - - if (!team) { - redirect("/team"); - } - - if (!project) { - redirect(`/team/${team_slug}`); - } - - const client = getClientThirdwebClient({ - jwt: authToken, - teamId: team.id, - }); - - const bundlerService = project.services.find((s) => s.name === "bundler"); - - return ( -
- - -
- {!bundlerService ? ( - - - Account Abstraction service is disabled - - Enable Account Abstraction service in{" "} - - project settings - {" "} - to configure the sponsorship rules - - - ) : ( - <> - - - - - - )} -
-
+ redirect( + `/team/${team_slug}/${project_slug}/wallets/sponsored-gas/configuration`, ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/wallets/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/wallets/page.tsx index 38319af7c89..ee4f2ee8036 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/wallets/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/wallets/page.tsx @@ -1,59 +1,11 @@ import { redirect } from "next/navigation"; -import { getAuthToken } from "@/api/auth-token"; -import { getProject } from "@/api/project/projects"; -import { getTeamBySlug } from "@/api/team/get-team"; -import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { getValidTeamPlan } from "@/utils/getValidTeamPlan"; -import { loginRedirect } from "@/utils/redirects"; -import { ProjectSettingsBreadcrumb } from "../_components/project-settings-breadcrumb"; -import { getSMSCountryTiers } from "./api/sms"; -import { InAppWalletSettingsPage } from "./components"; +// Redirect old settings/wallets page to new User Wallets Configuration export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string }>; }) { const { team_slug, project_slug } = await props.params; - - const [team, project, smsCountryTiers, authToken] = await Promise.all([ - getTeamBySlug(team_slug), - getProject(team_slug, project_slug), - getSMSCountryTiers(), - getAuthToken(), - ]); - - if (!authToken) { - loginRedirect(`/team/${team_slug}/settings/wallets`); - } - - if (!team) { - redirect("/team"); - } - - if (!project) { - redirect(`/team/${team_slug}`); - } - - const client = getClientThirdwebClient({ - jwt: authToken, - teamId: team.id, - }); - - return ( -
- - - -
+ redirect( + `/team/${team_slug}/${project_slug}/wallets/user-wallets/configuration`, ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/page.tsx index e9a37e0525b..ef2ce8670e2 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/page.tsx @@ -1,224 +1,11 @@ -import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk"; -import { ArrowLeftRightIcon } from "lucide-react"; -import { notFound, redirect } from "next/navigation"; -import { getAuthToken } from "@/api/auth-token"; -import { getProject } from "@/api/project/projects"; -import { ProjectPage } from "@/components/blocks/project-page/project-page"; -import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; -import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { TransactionsAnalyticsPageContent } from "./analytics/analytics-page"; -import { EngineChecklist } from "./analytics/ftux.client"; -import { TransactionAnalyticsSummary } from "./analytics/summary"; -import { ServerWalletsTable } from "./components/server-wallets-table.client"; -import { getTransactionAnalyticsSummary } from "./lib/analytics"; -import type { Wallet } from "./server-wallets/wallet-table/types"; -import { listSolanaAccounts } from "./solana-wallets/lib/vault.client"; -import type { SolanaWallet } from "./solana-wallets/wallet-table/types"; +import { redirect } from "next/navigation"; -export const dynamic = "force-dynamic"; - -export default async function TransactionsAnalyticsPage(props: { +// Redirect old Transactions page to new Server Wallets Overview +export default async function Page(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; - testSolanaTxWithWallet?: string | string[] | undefined; - page?: string; - solana_page?: string; - }>; }) { - const [params, searchParams, authToken] = await Promise.all([ - props.params, - props.searchParams, - getAuthToken(), - ]); - - if (!authToken) { - notFound(); - } - - const [vaultClient, project] = await Promise.all([ - createVaultClient({ - baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL, - }).catch(() => undefined), - getProject(params.team_slug, params.project_slug), - ]); - - if (!project) { - redirect(`/team/${params.team_slug}`); - } - - if (!vaultClient) { - return
Error: Failed to connect to Vault
; - } - - const projectEngineCloudService = project.services.find( - (service) => service.name === "engineCloud", - ); - - const managementAccessToken = - projectEngineCloudService?.managementAccessToken; - const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey; - - const pageSize = 10; - const currentPage = Number.parseInt(searchParams.page ?? "1"); - const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1"); - - const eoas = managementAccessToken - ? await listEoas({ - client: vaultClient, - request: { - auth: { - accessToken: managementAccessToken, - }, - options: { - page: currentPage - 1, - // @ts-expect-error - TODO: fix this - page_size: pageSize, - }, - }, - }) - : { data: { items: [], totalRecords: 0 }, error: null, success: true }; - - const wallets = eoas.data?.items as Wallet[] | undefined; - - // Fetch Solana accounts - gracefully handle permission errors - let solanaAccounts: { - data: { items: SolanaWallet[]; totalRecords: number }; - error: Error | null; - success: boolean; - }; - - if (managementAccessToken) { - solanaAccounts = await listSolanaAccounts({ - managementAccessToken, - page: solanCurrentPage, - limit: pageSize, - projectId: project.id, - }); - } else { - solanaAccounts = { - data: { items: [], totalRecords: 0 }, - error: null, - success: true, - }; - } - - // Check if error is a permission error - const isSolanaPermissionError = - solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false; - - const initialData = await getTransactionAnalyticsSummary({ - clientId: project.publishableKey, - teamId: project.teamId, - }).catch(() => undefined); - const hasTransactions = initialData ? initialData.totalCount > 0 : false; - - const client = getClientThirdwebClient({ - jwt: authToken, - teamId: project.teamId, - }); - - return ( - - Send, monitor, and manage transactions.{" "} -
Send transactions from user or - server wallets, sponsor gas, monitor transaction status, and more - - ), - actions: null, - links: [ - { - type: "docs", - href: "https://portal.thirdweb.com/transactions", - }, - { - type: "playground", - href: "https://playground.thirdweb.com/transactions/airdrop-tokens", - }, - { - type: "api", - href: "https://api.thirdweb.com/reference#tag/transactions", - }, - ], - }} - > -
- - {hasTransactions && - !searchParams.testTxWithWallet && - !searchParams.testSolanaTxWithWallet && ( - - )} - - {/* transactions */} - - - {/* Server Wallets (EVM + Solana) */} - {eoas.error ? ( -
-

- EVM Wallet Error -

-

- {eoas.error.message} -

-
- ) : ( - - )} -
-
+ const params = await props.params; + redirect( + `/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets/overview`, ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/page.tsx index 6c4455e2e1d..2fced3d5b6e 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/page.tsx @@ -1,58 +1,11 @@ -import { LockIcon } from "lucide-react"; -import { notFound } from "next/navigation"; -import { getAuthToken } from "@/api/auth-token"; -import { getProject } from "@/api/project/projects"; -import { ProjectPage } from "@/components/blocks/project-page/project-page"; -import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { KeyManagement } from "./components/key-management"; +import { redirect } from "next/navigation"; -export default async function VaultPage(props: { +// Redirect old Vault page to new Server Wallets Configuration +export default async function Page(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; - const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey; - - const client = getClientThirdwebClient({ - jwt: authToken, - teamId: project.teamId, - }); - - return ( - - - + const params = await props.params; + redirect( + `/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets/configuration`, ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/layout.tsx new file mode 100644 index 00000000000..466b1a5a1f3 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/layout.tsx @@ -0,0 +1,62 @@ +import { redirect } from "next/navigation"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/project/projects"; +import { ProjectPage } from "@/components/blocks/project-page/project-page"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { WalletProductIcon } from "@/icons/WalletProductIcon"; +import { loginRedirect } from "@/utils/redirects"; + +export default async function WalletsLayout(props: { + children: React.ReactNode; + params: Promise<{ team_slug: string; project_slug: string }>; +}) { + const params = await props.params; + const [authToken, project] = await Promise.all([ + getAuthToken(), + getProject(params.team_slug, params.project_slug), + ]); + + if (!authToken) { + loginRedirect( + `/team/${params.team_slug}/${params.project_slug}/wallets/user-wallets`, + ); + } + + if (!project) { + redirect(`/team/${params.team_slug}`); + } + + const client = getClientThirdwebClient({ + jwt: authToken, + teamId: project.teamId, + }); + + return ( + + {props.children} + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/page.tsx index 0c080f97206..8767a96f461 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/page.tsx @@ -1,226 +1,10 @@ -import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk"; import { redirect } from "next/navigation"; -import { ResponsiveSearchParamsProvider } from "responsive-rsc"; -import { getAuthToken } from "@/api/auth-token"; -import { getProject } from "@/api/project/projects"; -import type { DurationId } from "@/components/analytics/date-range-selector"; -import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters"; -import { ProjectPage } from "@/components/blocks/project-page/project-page"; -import { InAppWalletUsersPageContent } from "@/components/in-app-wallet-users-content/in-app-wallet-users-content"; -import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; -import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { WalletProductIcon } from "@/icons/WalletProductIcon"; -import { getFiltersFromSearchParams } from "@/lib/time"; -import { loginRedirect } from "@/utils/redirects"; -import { ServerWalletsTable } from "../transactions/components/server-wallets-table.client"; -import type { Wallet } from "../transactions/server-wallets/wallet-table/types"; -import { listSolanaAccounts } from "../transactions/solana-wallets/lib/vault.client"; -import type { SolanaWallet } from "../transactions/solana-wallets/wallet-table/types"; -import { InAppWalletAnalytics } from "./analytics/chart"; -import { InAppWalletsSummary } from "./analytics/chart/Summary"; - -export const dynamic = "force-dynamic"; export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string }>; - searchParams: Promise<{ - from?: string; - to?: string; - type?: string; - interval?: string; - page?: string; - solana_page?: string; - }>; }) { - const [searchParams, params] = await Promise.all([ - props.searchParams, - props.params, - ]); - - const authToken = await getAuthToken(); - if (!authToken) { - loginRedirect(`/team/${params.team_slug}/${params.project_slug}/wallets`); - } - - const defaultRange: DurationId = "last-30"; - const { range, interval } = getFiltersFromSearchParams({ - defaultRange, - from: searchParams.from, - interval: searchParams.interval, - to: searchParams.to, - }); - - const [vaultClient, project] = await Promise.all([ - createVaultClient({ - baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL, - }).catch(() => undefined), - getProject(params.team_slug, params.project_slug), - ]); - - if (!project) { - redirect(`/team/${params.team_slug}`); - } - - const projectEngineCloudService = project.services.find( - (service) => service.name === "engineCloud", - ); - - const managementAccessToken = - projectEngineCloudService?.managementAccessToken; - - // Fetch server wallets with pagination (5 per page) - const pageSize = 5; - const currentPage = Number.parseInt(searchParams.page ?? "1"); - const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1"); - - // Fetch EVM wallets - const eoas = - vaultClient && managementAccessToken - ? await listEoas({ - client: vaultClient, - request: { - auth: { - accessToken: managementAccessToken, - }, - options: { - page: currentPage - 1, - // @ts-expect-error - TODO: fix this - page_size: pageSize, - }, - }, - }) - : { data: { items: [], totalRecords: 0 }, error: null, success: true }; - - // Fetch Solana wallets - let solanaAccounts: { - data: { items: SolanaWallet[]; totalRecords: number }; - error: Error | null; - success: boolean; - }; - - if (managementAccessToken) { - solanaAccounts = await listSolanaAccounts({ - managementAccessToken, - page: solanCurrentPage, - limit: pageSize, - projectId: project.id, - }); - } else { - solanaAccounts = { - data: { items: [], totalRecords: 0 }, - error: null, - success: true, - }; - } - - // Check for Solana permission errors - const isSolanaPermissionError = solanaAccounts.error?.message?.includes( - "AUTH_INSUFFICIENT_SCOPE", - ); - - const client = getClientThirdwebClient({ - jwt: authToken, - teamId: project.teamId, - }); - - return ( - - - Create wallets for your users with flexible authentication - options. -
Choose from email/phone - verification, OAuth, passkeys, or external wallet connections - - ), - actions: null, - settings: { - href: `/team/${params.team_slug}/${params.project_slug}/settings/wallets`, - }, - links: [ - { - type: "docs", - href: "https://portal.thirdweb.com/wallets", - }, - { - type: "playground", - href: "https://playground.thirdweb.com/wallets/in-app-wallet", - }, - { - type: "api", - href: "https://api.thirdweb.com/reference#tag/wallets", - }, - ], - }} - > -
- - - - - - {/* Server Wallets Section (EVM + Solana) */} -
- {eoas.error ? ( -
-

- EVM Wallet Error -

-

- {eoas.error.message || "Failed to load EVM wallets"} -

-
- ) : ( - - )} -
- - {/* User Wallets Section */} -
-

- User wallets -

- -
-
-
-
+ const params = await props.params; + redirect( + `/team/${params.team_slug}/${params.project_slug}/wallets/user-wallets/overview`, ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/configuration/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/configuration/page.tsx new file mode 100644 index 00000000000..89916b46b21 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/configuration/page.tsx @@ -0,0 +1,40 @@ +import { redirect } from "next/navigation"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/project/projects"; +import { loginRedirect } from "@/utils/redirects"; +import { KeyManagement } from "../../../vault/components/key-management"; + +export default async function Page(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 (!authToken) { + loginRedirect( + `/team/${team_slug}/${project_slug}/wallets/server-wallets/configuration`, + ); + } + + if (!project) { + redirect(`/team/${team_slug}`); + } + + const projectEngineCloudService = project.services.find( + (service) => service.name === "engineCloud", + ); + + const maskedAdminKey = projectEngineCloudService?.maskedAdminKey; + const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey; + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/layout.tsx new file mode 100644 index 00000000000..c414ef4f81c --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/layout.tsx @@ -0,0 +1,27 @@ +import { TabPathLinks } from "@/components/ui/tabs"; + +export default async function Layout(props: { + children: React.ReactNode; + params: Promise<{ team_slug: string; project_slug: string }>; +}) { + const params = await props.params; + const basePath = `/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets`; + + return ( +
+ + {props.children} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/overview/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/overview/page.tsx new file mode 100644 index 00000000000..2da3395af79 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/overview/page.tsx @@ -0,0 +1,185 @@ +import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk"; +import { redirect } from "next/navigation"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/project/projects"; +import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { TransactionsAnalyticsPageContent } from "../../../transactions/analytics/analytics-page"; +import { EngineChecklist } from "../../../transactions/analytics/ftux.client"; +import { TransactionAnalyticsSummary } from "../../../transactions/analytics/summary"; +import { ServerWalletsTable } from "../../../transactions/components/server-wallets-table.client"; +import { getTransactionAnalyticsSummary } from "../../../transactions/lib/analytics"; +import type { Wallet } from "../../../transactions/server-wallets/wallet-table/types"; +import { listSolanaAccounts } from "../../../transactions/solana-wallets/lib/vault.client"; +import type { SolanaWallet } from "../../../transactions/solana-wallets/wallet-table/types"; + +export const dynamic = "force-dynamic"; + +export default async function Page(props: { + params: Promise<{ team_slug: string; project_slug: string }>; + searchParams: Promise<{ + from?: string | string[]; + to?: string | string[]; + interval?: string | string[]; + testTxWithWallet?: string | string[]; + testSolanaTxWithWallet?: string | string[]; + page?: string; + solana_page?: string; + }>; +}) { + const [params, searchParams, authToken] = await Promise.all([ + props.params, + props.searchParams, + getAuthToken(), + ]); + + if (!authToken) { + redirect( + `/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets/overview`, + ); + } + + const [vaultClient, project] = await Promise.all([ + createVaultClient({ + baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL, + }).catch(() => undefined), + getProject(params.team_slug, params.project_slug), + ]); + + if (!project) { + redirect(`/team/${params.team_slug}`); + } + + const projectEngineCloudService = project.services.find( + (service) => service.name === "engineCloud", + ); + + const managementAccessToken = + projectEngineCloudService?.managementAccessToken; + const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey; + + const pageSize = 10; + const currentPage = Number.parseInt(searchParams.page ?? "1"); + const solanaCurrentPage = Number.parseInt(searchParams.solana_page ?? "1"); + + const eoas = + managementAccessToken && vaultClient + ? await listEoas({ + client: vaultClient, + request: { + auth: { + accessToken: managementAccessToken, + }, + options: { + page: currentPage - 1, + // @ts-expect-error - TODO: fix this + page_size: pageSize, + }, + }, + }) + : { data: { items: [], totalRecords: 0 }, error: null, success: true }; + + const wallets = eoas.data?.items as Wallet[] | undefined; + + let solanaAccounts: { + data: { items: SolanaWallet[]; totalRecords: number }; + error: Error | null; + success: boolean; + }; + + if (managementAccessToken) { + solanaAccounts = await listSolanaAccounts({ + managementAccessToken, + page: solanaCurrentPage, + limit: pageSize, + projectId: project.id, + }); + } else { + solanaAccounts = { + data: { items: [], totalRecords: 0 }, + error: null, + success: true, + }; + } + + const isSolanaPermissionError = + solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false; + + const initialData = await getTransactionAnalyticsSummary({ + clientId: project.publishableKey, + teamId: project.teamId, + }).catch(() => undefined); + const hasTransactions = initialData ? initialData.totalCount > 0 : false; + + const client = getClientThirdwebClient({ + jwt: authToken, + teamId: project.teamId, + }); + + return ( +
+ + {hasTransactions && + !searchParams.testTxWithWallet && + !searchParams.testSolanaTxWithWallet && ( + + )} + + + + {eoas.error ? ( +
+

+ EVM Wallet Error +

+

{eoas.error.message}

+
+ ) : ( + + )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/page.tsx new file mode 100644 index 00000000000..fd0c6686f24 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +export default async function Page(props: { + params: Promise<{ team_slug: string; project_slug: string }>; +}) { + const params = await props.params; + redirect( + `/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets/overview`, + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/configuration/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/configuration/page.tsx new file mode 100644 index 00000000000..1ab1ab0a8b1 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/configuration/page.tsx @@ -0,0 +1,85 @@ +import { CircleAlertIcon } from "lucide-react"; +import { redirect } from "next/navigation"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/project/projects"; +import { getTeamBySlug } from "@/api/team/get-team"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { getValidTeamPlan } from "@/utils/getValidTeamPlan"; +import { loginRedirect } from "@/utils/redirects"; +import { DefaultFactoriesSection } from "../../../account-abstraction/factories/AccountFactories"; +import { YourFactoriesSection } from "../../../account-abstraction/factories/AccountFactories/your-factories"; +import { AccountAbstractionSettingsPage } from "../../../account-abstraction/settings/SponsorshipPolicies"; + +export default async function Page(props: { + params: Promise<{ team_slug: string; project_slug: string }>; +}) { + const { team_slug, project_slug } = await props.params; + + const [team, project, authToken] = await Promise.all([ + getTeamBySlug(team_slug), + getProject(team_slug, project_slug), + getAuthToken(), + ]); + + if (!authToken) { + loginRedirect( + `/team/${team_slug}/${project_slug}/wallets/sponsored-gas/configuration`, + ); + } + + if (!team) { + redirect("/team"); + } + + if (!project) { + redirect(`/team/${team_slug}`); + } + + const client = getClientThirdwebClient({ + jwt: authToken, + teamId: team.id, + }); + + const bundlerService = project.services.find((s) => s.name === "bundler"); + + return ( +
+ {!bundlerService ? ( + + + Account Abstraction service is disabled + + Enable Account Abstraction service in{" "} + + project settings + {" "} + to configure the sponsorship rules + + + ) : ( + <> + + + + + + )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/layout.tsx new file mode 100644 index 00000000000..8f3f5f6fc19 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/layout.tsx @@ -0,0 +1,27 @@ +import { TabPathLinks } from "@/components/ui/tabs"; + +export default async function Layout(props: { + children: React.ReactNode; + params: Promise<{ team_slug: string; project_slug: string }>; +}) { + const params = await props.params; + const basePath = `/team/${params.team_slug}/${params.project_slug}/wallets/sponsored-gas`; + + return ( +
+ + {props.children} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/overview/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/overview/page.tsx new file mode 100644 index 00000000000..4abeb963a70 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/overview/page.tsx @@ -0,0 +1,111 @@ +import { notFound, redirect } from "next/navigation"; +import type { SearchParams } from "nuqs/server"; +import { getUserOpUsage } from "@/api/analytics"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/project/projects"; +import { getTeamBySlug } from "@/api/team/get-team"; +import { + getLastNDaysRange, + type Range, +} from "@/components/analytics/date-range-selector"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { AccountAbstractionSummary } from "../../../account-abstraction/AccountAbstractionAnalytics/AccountAbstractionSummary"; +import { SmartWalletsBillingAlert } from "../../../account-abstraction/Alerts"; +import { AccountAbstractionAnalytics } from "../../../account-abstraction/aa-analytics"; +import { searchParamLoader } from "../../../account-abstraction/search-params"; + +export const dynamic = "force-dynamic"; + +export default async function Page(props: { + params: Promise<{ team_slug: string; project_slug: string }>; + searchParams: Promise; +}) { + const [params, searchParams, authToken] = await Promise.all([ + props.params, + searchParamLoader(props.searchParams), + getAuthToken(), + ]); + + if (!authToken) { + notFound(); + } + + const [team, project] = await Promise.all([ + getTeamBySlug(params.team_slug), + getProject(params.team_slug, params.project_slug), + ]); + + if (!team) { + redirect("/team"); + } + + if (!project) { + redirect(`/team/${params.team_slug}`); + } + + const interval = searchParams.interval ?? "week"; + const rangeType = searchParams.range || "last-120"; + + const range: Range = { + from: + rangeType === "custom" + ? searchParams.from + : getLastNDaysRange(rangeType).from, + to: + rangeType === "custom" + ? searchParams.to + : getLastNDaysRange(rangeType).to, + type: rangeType, + }; + + const userOpStats = await getUserOpUsage( + { + from: range.from, + period: interval, + projectId: project.id, + teamId: project.teamId, + to: range.to, + }, + authToken, + ); + + const client = getClientThirdwebClient({ + jwt: authToken, + teamId: project.teamId, + }); + + const isBundlerServiceEnabled = !!project.services.find( + (s) => s.name === "bundler", + ); + + const hasSmartWalletsWithoutBilling = + isBundlerServiceEnabled && + team.billingStatus !== "validPayment" && + team.billingStatus !== "pastDue"; + + return ( + <> + {hasSmartWalletsWithoutBilling && ( + <> + +
+ + )} +
+ + + +
+ + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/page.tsx new file mode 100644 index 00000000000..8de1824fbf6 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +export default async function Page(props: { + params: Promise<{ team_slug: string; project_slug: string }>; +}) { + const params = await props.params; + redirect( + `/team/${params.team_slug}/${params.project_slug}/wallets/sponsored-gas/overview`, + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/api/sms.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/api/sms.ts new file mode 100644 index 00000000000..0ddb49eb77f --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/api/sms.ts @@ -0,0 +1,61 @@ +import "server-only"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; +import { API_SERVER_SECRET } from "@/constants/server-envs"; + +export type SMSCountryTiers = { + tier1: string[]; + tier2: string[]; + tier3: string[]; + tier4: string[]; + tier5: string[]; + tier6: string[]; +}; + +export async function getSMSCountryTiers() { + if (!API_SERVER_SECRET) { + throw new Error("API_SERVER_SECRET is not set"); + } + const res = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/sms/list-country-tiers`, + { + headers: { + "Content-Type": "application/json", + "x-service-api-key": API_SERVER_SECRET, + }, + next: { + revalidate: 15 * 60, //15 minutes + }, + }, + ); + + if (!res.ok) { + console.error( + "Failed to fetch sms country tiers", + res.status, + res.statusText, + ); + res.body?.cancel(); + return { + tier1: [], + tier2: [], + tier3: [], + tier4: [], + tier5: [], + tier6: [], + }; + } + + try { + return (await res.json()).data as SMSCountryTiers; + } catch (e) { + console.error("Failed to parse sms country tiers", e); + return { + tier1: [], + tier2: [], + tier3: [], + tier4: [], + tier5: [], + tier6: [], + }; + } +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/index.tsx new file mode 100644 index 00000000000..af25993589c --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/index.tsx @@ -0,0 +1,891 @@ +"use client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import type { ProjectEmbeddedWalletsService } from "@thirdweb-dev/service-utils"; +import { CircleAlertIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import type React from "react"; +import { useState } from "react"; +import { type UseFormReturn, useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { ThirdwebClient } from "thirdweb"; +import { upload } from "thirdweb/storage"; +import type { Project } from "@/api/project/projects"; +import type { Team } from "@/api/team/get-team"; +import { FileInput } from "@/components/blocks/FileInput"; +import { GatedSwitch } from "@/components/blocks/GatedSwitch"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { DynamicHeight } from "@/components/ui/DynamicHeight"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/Spinner"; +import { Textarea } from "@/components/ui/textarea"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; +import { planToTierRecordForGating } from "@/constants/planToTierRecord"; +import { updateProjectClient } from "@/hooks/useApi"; +import { cn } from "@/lib/utils"; +import { + type ApiKeyEmbeddedWalletsValidationSchema, + apiKeyEmbeddedWalletsValidationSchema, +} from "@/schema/validations"; +import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler"; +import { toArrFromList } from "@/utils/string"; +import type { SMSCountryTiers } from "../api/sms"; +import { CountrySelector } from "./sms-country-select"; + +type InAppWalletSettingsPageProps = { + project: Project; + teamId: string; + teamSlug: string; + teamPlan: Team["billingPlan"]; + smsCountryTiers: SMSCountryTiers; + client: ThirdwebClient; +}; + +type UpdateAPIKeyTrackingData = { + hasCustomBranding: boolean; + hasCustomJwt: boolean; + hasCustomAuthEndpoint: boolean; +}; + +export function InAppWalletSettingsPage(props: InAppWalletSettingsPageProps) { + const updateProject = useMutation({ + mutationFn: async (projectValues: Partial) => { + await updateProjectClient( + { + projectId: props.project.id, + teamId: props.teamId, + }, + projectValues, + ); + }, + }); + + function handleUpdateProject(projectValues: Partial) { + updateProject.mutate(projectValues, { + onError: (err) => { + toast.error("Failed to update an API Key"); + console.error(err); + }, + onSuccess: () => { + toast.success("In-App Wallet API Key configuration updated"); + }, + }); + } + + return ( + + ); +} + +const InAppWalletSettingsPageUI: React.FC< + InAppWalletSettingsPageProps & { + updateApiKey: ( + projectValues: Partial, + trackingData: UpdateAPIKeyTrackingData, + ) => void; + isUpdating: boolean; + smsCountryTiers: SMSCountryTiers; + } +> = (props) => { + const embeddedWalletService = props.project.services.find( + (service) => service.name === "embeddedWallets", + ); + + if (!embeddedWalletService) { + return ( + + + In-App wallets service is disabled + + Enable In-App wallets service in the{" "} + + project settings + {" "} + to configure settings + + + ); + } + + return ( + + ); +}; + +export const InAppWalletSettingsUI: React.FC< + InAppWalletSettingsPageProps & { + updateApiKey: ( + projectValues: Partial, + trackingData: UpdateAPIKeyTrackingData, + ) => void; + isUpdating: boolean; + embeddedWalletService: ProjectEmbeddedWalletsService; + client: ThirdwebClient; + } +> = (props) => { + const services = props.project.services; + + const config = props.embeddedWalletService; + + const hasCustomBranding = + !!config.applicationImageUrl?.length || !!config.applicationName?.length; + + const authRequiredPlan = "growth"; + const brandingRequiredPlan = "starter"; + + // growth or higher plan required + const canEditSmsCountries = + planToTierRecordForGating[props.teamPlan] >= + planToTierRecordForGating[authRequiredPlan]; + + const form = useForm({ + resolver: zodResolver(apiKeyEmbeddedWalletsValidationSchema), + values: { + customAuthEndpoint: config.customAuthEndpoint || undefined, + customAuthentication: config.customAuthentication || undefined, + ...(hasCustomBranding + ? { + branding: { + applicationImageUrl: config.applicationImageUrl || undefined, + applicationName: config.applicationName || undefined, + }, + } + : undefined), + redirectUrls: (config.redirectUrls || []).join("\n"), + smsEnabledCountryISOs: config.smsEnabledCountryISOs + ? config.smsEnabledCountryISOs + : canEditSmsCountries + ? ["US", "CA"] + : [], + }, + }); + + const handleSubmit = form.handleSubmit((values) => { + const { customAuthentication, customAuthEndpoint, branding, redirectUrls } = + values; + + if ( + customAuthentication && + (!customAuthentication.aud.length || !customAuthentication.jwksUri.length) + ) { + return toast.error("Custom JSON Web Token configuration is invalid", { + description: + "To use In-App Wallets with Custom JSON Web Token, provide JWKS URI and AUD.", + dismissible: true, + duration: 9000, + }); + } + + if (customAuthEndpoint && !customAuthEndpoint.authEndpoint.length) { + return toast.error( + "Custom Authentication Endpoint configuration is invalid", + { + description: + "To use In-App Wallets with Custom Authentication Endpoint, provide a valid URL.", + dismissible: true, + duration: 9000, + }, + ); + } + + const newServices = services.map((service) => { + if (service.name !== "embeddedWallets") { + return service; + } + + return { + ...service, + applicationImageUrl: branding?.applicationImageUrl, + applicationName: branding?.applicationName || props.project.name, + customAuthEndpoint, + customAuthentication, + redirectUrls: toArrFromList(redirectUrls || "", true), + smsEnabledCountryISOs: values.smsEnabledCountryISOs, + }; + }); + + props.updateApiKey( + { + services: newServices, + }, + { + hasCustomAuthEndpoint: !!customAuthEndpoint, + hasCustomBranding: !!branding, + hasCustomJwt: !!customAuthentication, + }, + ); + }); + + return ( +
+ + {/* Branding */} + + + + + {/* Authentication */} +
+ +
+ } + > + + +
+ + + +
+ + + + + + ); +}; + +function BrandingFieldset(props: { + form: UseFormReturn; + teamPlan: Team["billingPlan"]; + teamSlug: string; + requiredPlan: Team["billingPlan"]; + client: ThirdwebClient; + isUpdating: boolean; +}) { + return ( + + +
+ } + > +
+ + props.form.setValue( + "branding", + checked + ? { + applicationImageUrl: "", + applicationName: "", + } + : undefined, + ), + }} + teamSlug={props.teamSlug} + trackingLabel="customEmailLogoAndName" + /> +
+ + +
+ ( + +
+ Application Image URL + + Logo that will display in the emails sent to users.{" "} +
The image must be squared + with recommended size of 72x72 px. +
+ + +
+ + + { + props.form.setValue("branding.applicationImageUrl", uri, { + shouldDirty: true, + shouldTouch: true, + }); + }} + uri={props.form.watch("branding.applicationImageUrl")} + /> + +
+ )} + /> + + {/* Application Name */} + ( + + Application Name + + Name that will be displayed in the emails sent to users.{" "} +
Defaults to your API Key's + name. +
+ + + + +
+ )} + /> +
+
+ + ); +} + +function AppImageFormControl(props: { + uri: string | undefined; + setUri: (uri: string) => void; + client: ThirdwebClient; +}) { + const [image, setImage] = useState(); + const resolveUrl = resolveSchemeWithErrorHandler({ + client: props.client, + uri: props.uri || undefined, + }); + + const uploadImage = useMutation({ + mutationFn: async (file: File) => { + const uri = await upload({ + client: props.client, + files: [file], + }); + + return uri; + }, + }); + + return ( +
+
+ { + try { + setImage(v); + const uri = await uploadImage.mutateAsync(v); + props.setUri(uri); + } catch (error) { + setImage(undefined); + toast.error("Failed to upload image", { + description: error instanceof Error ? error.message : undefined, + }); + } + }} + value={image || resolveUrl} + /> + + {uploadImage.isPending && ( +
+ +
+ )} +
+
+ ); +} + +function SMSCountryFields(props: { + form: UseFormReturn; + smsCountryTiers: SMSCountryTiers; + teamPlan: Team["billingPlan"]; + requiredPlan: Team["billingPlan"]; + teamSlug: string; +}) { + return ( +
+ + + props.form.setValue( + "smsEnabledCountryISOs", + checked ? ["US", "CA"] : [], + ), + }} + teamSlug={props.teamSlug} + trackingLabel="sms" + /> + + + + ( + + )} + /> + +
+ ); +} + +function JSONWebTokenFields(props: { + form: UseFormReturn; + teamPlan: Team["billingPlan"]; + teamSlug: string; + requiredPlan: Team["billingPlan"]; +}) { + return ( +
+ + Optionally allow users to authenticate with a custom JWT.{" "} + + Learn more + + + } + switchId="authentication-switch" + title="Custom JSON Web Token" + > + { + props.form.setValue( + "customAuthentication", + checked ? { aud: "", jwksUri: "" } : undefined, + ); + }, + }} + teamSlug={props.teamSlug} + trackingLabel="customAuthJWT" + /> + + + + ( + + JWKS URI + + + + Enter the URI of the JWKS + + + )} + /> + + ( + + AUD Value + + + + + Enter the audience claim for the JWT + + + + )} + /> + +
+ ); +} + +function AuthEndpointFields(props: { + form: UseFormReturn; + teamPlan: Team["billingPlan"]; + teamSlug: string; + requiredPlan: Team["billingPlan"]; +}) { + const expandCustomAuthEndpointField = + props.form.watch("customAuthEndpoint") !== undefined; + + return ( +
+ + Optionally allow users to authenticate with any arbitrary payload + that you provide.{" "} + + Learn more + + + } + switchId="auth-endpoint-switch" + title="Custom Authentication Endpoint" + > + { + props.form.setValue( + "customAuthEndpoint", + checked + ? { + authEndpoint: "", + customHeaders: [], + } + : undefined, + ); + }, + }} + teamSlug={props.teamSlug} + trackingLabel="customAuthEndpoint" + /> + + + {/* useFieldArray used on this component - it creates empty customAuthEndpoint.customHeaders array on mount */} + {/* So only mount if expandCustomAuthEndpointField is true */} + {expandCustomAuthEndpointField && ( + + )} +
+ ); +} + +function AuthEndpointFieldsContent(props: { + form: UseFormReturn; +}) { + const customHeaderFields = useFieldArray({ + control: props.form.control, + name: "customAuthEndpoint.customHeaders", + }); + + return ( +
+ ( + + Authentication Endpoint + + + + + Enter the URL of your server where we will send the user payload + for verification + + + + )} + /> + +
+ +
+ {customHeaderFields.fields.map((field, customHeaderIdx) => { + return ( +
+ + + +
+ ); + })} + + +
+ +

+ Set custom headers to be sent along the request with the payload to + the authentication endpoint above. This can be used to verify the + incoming requests +

+
+
+ ); +} + +function NativeAppsFieldset(props: { + form: UseFormReturn; + isUpdating: boolean; +}) { + const { form } = props; + return ( +
+ +
+ } + > + ( + + Allowed redirect URIs + +