diff --git a/.changeset/funny-dragons-thank.md b/.changeset/funny-dragons-thank.md new file mode 100644 index 00000000000..b7fa4e1ae74 --- /dev/null +++ b/.changeset/funny-dragons-thank.md @@ -0,0 +1,5 @@ +--- +"@thirdweb-dev/service-utils": patch +--- + +Update TeamResponse and ProjectResponse types diff --git a/apps/dashboard/src/@/api/projects.ts b/apps/dashboard/src/@/api/projects.ts index f45ecf02496..9683f205f7e 100644 --- a/apps/dashboard/src/@/api/projects.ts +++ b/apps/dashboard/src/@/api/projects.ts @@ -1,23 +1,9 @@ import "server-only"; import { API_SERVER_URL } from "@/constants/env"; +import type { ProjectResponse } from "@thirdweb-dev/service-utils"; import { getAuthToken } from "../../app/api/lib/getAuthToken"; -export type Project = { - id: string; - name: string; - createdAt: Date; - updatedAt: Date; - deletedAt: Date | null; - bannedAt: Date | null; - domains: string[]; - bundleIds: string[]; - redirectUrls: string[]; - lastAccessedAt: Date | null; - slug: string; - teamId: string; - publishableKey: string; - // image: string; // TODO -}; +export type Project = ProjectResponse; export async function getProjects(teamSlug: string) { const token = await getAuthToken(); diff --git a/apps/dashboard/src/@/api/team.ts b/apps/dashboard/src/@/api/team.ts index 5e3823ca011..efdb350da83 100644 --- a/apps/dashboard/src/@/api/team.ts +++ b/apps/dashboard/src/@/api/team.ts @@ -1,35 +1,9 @@ import "server-only"; import { API_SERVER_URL } from "@/constants/env"; +import type { TeamResponse } from "@thirdweb-dev/service-utils"; import { getAuthToken } from "../../app/api/lib/getAuthToken"; -type EnabledTeamScope = - | "pay" - | "storage" - | "rpc" - | "bundler" - | "insight" - | "embeddedWallets" - | "relayer" - | "chainsaw" - | "nebula"; - -export type Team = { - id: string; - name: string; - slug: string; - createdAt: string; - updatedAt: string; - deletedAt?: string; - bannedAt?: string; - image?: string; - billingPlan: "pro" | "growth" | "free" | "starter"; - billingStatus: "validPayment" | (string & {}) | null; - supportPlan: "pro" | "growth" | "free" | "starter"; - billingEmail: string | null; - growthTrialEligible: false; - enabledScopes: EnabledTeamScope[]; -}; - +export type Team = TeamResponse; export async function getTeamBySlug(slug: string) { const token = await getAuthToken(); diff --git a/apps/dashboard/src/@3rdweb-sdk/react/cache-keys.ts b/apps/dashboard/src/@3rdweb-sdk/react/cache-keys.ts index accbcf2fa95..ee3b5ca6f84 100644 --- a/apps/dashboard/src/@3rdweb-sdk/react/cache-keys.ts +++ b/apps/dashboard/src/@3rdweb-sdk/react/cache-keys.ts @@ -42,15 +42,6 @@ export const accountKeys = { [...accountKeys.wallet(walletAddress), "billing-session"] as const, }; -export const apiKeys = { - all: ["api"] as const, - wallet: (walletAddress: string) => [...apiKeys.all, walletAddress] as const, - keys: (walletAddress: string) => - [...apiKeys.wallet(walletAddress), "keys"] as const, - key: (id: string, walletAddress: string) => - [...apiKeys.keys(walletAddress), id] as const, -}; - export const authorizedWallets = { all: ["authorizedWallets"] as const, wallet: (walletAddress: string) => diff --git a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts index cfb6b1c753b..926077f6925 100644 --- a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts +++ b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts @@ -1,22 +1,14 @@ import { analyticsServerProxy, apiServerProxy } from "@/actions/proxies"; +import type { Project } from "@/api/projects"; import type { Team } from "@/api/team"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAllChainsData } from "hooks/chains/allChains"; import { useActiveAccount } from "thirdweb/react"; import type { UserOpStats } from "types/analytics"; -import { accountKeys, apiKeys, authorizedWallets } from "../cache-keys"; +import { accountKeys, authorizedWallets } from "../cache-keys"; // FIXME: We keep repeating types, API server should provide them -export const accountStatus = { - noCustomer: "noCustomer", - noPayment: "noPayment", - paymentVerification: "paymentVerification", - validPayment: "validPayment", - invalidPayment: "invalidPayment", - invalidPaymentMethod: "invalidPaymentMethod", -} as const; - export const accountPlan = { free: "free", growth: "growth", @@ -71,109 +63,6 @@ interface ConfirmEmailInput { confirmationToken: string; } -type ApiKeyRecoverShareManagement = "AWS_MANAGED" | "USER_MANAGED"; -type ApiKeyCustomAuthentication = { - jwksUri: string; - aud: string; -}; -type ApiKeyCustomAuthEndpoint = { - authEndpoint: string; - customHeaders: { key: string; value: string }[]; -}; - -// MAP to api-server types in PolicyService.ts -export type ApiKeyServicePolicy = { - allowedChainIds?: number[] | null; - allowedContractAddresses?: string[] | null; - allowedWallets?: string[] | null; - blockedWallets?: string[] | null; - bypassWallets?: string[] | null; - serverVerifier?: { - url: string; - headers: { key: string; value: string }[] | null; - } | null; - limits?: ApiKeyServicePolicyLimits | null; -}; - -export type ApiKeyServicePolicyLimits = { - global?: { - // in dollars or ETH - maxSpend: string; - maxSpendUnit: "usd" | "native"; - } | null; - // ---------------------- - // TODO implement perUser limits - perUserSpend?: { - // in dollars or ETH - maxSpend: string | null; - maxSpendUnit: "usd" | "native"; - maxSpendPeriod: "day" | "week" | "month"; - } | null; - perUserTransactions?: { - maxTransactions: number; - maxTransactionsPeriod: "day" | "week" | "month"; - } | null; -}; - -export type ApiKeyService = { - id: string; - name: string; - targetAddresses: string[]; - actions: string[]; - // If updating here, need to update validation logic in `validation.ts` as well for recoveryShareManagement - // EMBEDDED WALLET - recoveryShareManagement?: ApiKeyRecoverShareManagement; - customAuthentication?: ApiKeyCustomAuthentication; - customAuthEndpoint?: ApiKeyCustomAuthEndpoint; - applicationName?: string; - applicationImageUrl?: string; - // PAY - payoutAddress?: string; -}; - -export type ApiKey = { - id: string; - name: string; - key: string; - secret?: string; - secretMasked: string; - accountId: string; - creatorWalletAddress: string; - walletAddresses: string[]; - domains: string[]; - bundleIds: string[]; - redirectUrls: string[]; - revokedAt: string; - lastAccessedAt: string; - createdAt: string; - updatedAt: string; - services?: ApiKeyService[]; -}; - -interface UpdateKeyServiceInput { - name: string; - targetAddresses: string[]; - actions?: string[]; -} - -export interface CreateKeyInput { - name?: string; - domains?: string[]; - bundleIds?: string[]; - walletAddresses?: string[]; - services?: UpdateKeyServiceInput[]; -} - -export interface UpdateKeyInput { - id: string; - name: string; - domains: string[]; - bundleIds: string[]; - walletAddresses?: string[]; - services?: UpdateKeyServiceInput[]; - redirectUrls: string[]; -} - interface UsageStorage { sumFileSizeBytes: number; } @@ -461,9 +350,6 @@ export function useUpdateNotifications() { } export function useConfirmEmail() { - const address = useActiveAccount()?.address; - const queryClient = useQueryClient(); - return useMutation({ mutationFn: async (input: ConfirmEmailInput) => { type Result = { @@ -492,20 +378,6 @@ export function useConfirmEmail() { return json.data; }, - onSuccess: async () => { - // invalidate related cache, since could be relinking account - return Promise.all([ - queryClient.invalidateQueries({ - queryKey: apiKeys.keys(address || ""), - }), - queryClient.invalidateQueries({ - queryKey: accountKeys.usage(address || ""), - }), - queryClient.invalidateQueries({ - queryKey: accountKeys.me(address || ""), - }), - ]); - }, }); } @@ -549,205 +421,106 @@ export function useResendEmailConfirmation() { }); } -export function useCreateApiKey() { - const address = useActiveAccount()?.address; - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (input: CreateKeyInput) => { - type Result = { - data: ApiKey; - error?: { message: string }; - }; - - const res = await apiServerProxy({ - pathname: "/v1/keys", - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(input), - }); - - if (!res.ok) { - throw new Error(res.error); - } - - const json = res.data; - - if (json.error) { - throw new Error(json.error.message); - } +export async function createProjectClient( + teamId: string, + body: Partial, +) { + type Response = { + result: { + project: Project; + secret: string; + }; + }; - return json.data; - }, - onSuccess: () => { - return queryClient.invalidateQueries({ - queryKey: apiKeys.keys(address || ""), - }); + const res = await apiServerProxy({ + pathname: `/v1/teams/${teamId}/projects`, + method: "POST", + headers: { + "Content-Type": "application/json", }, + body: JSON.stringify(body), }); -} - -export function useUpdateApiKey() { - const address = useActiveAccount()?.address; - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (input: UpdateKeyInput) => { - type Result = { - data: ApiKey; - error?: { message: string }; - }; - const res = await apiServerProxy({ - pathname: `/v1/keys/${input.id}`, - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(input), - }); - - if (!res.ok) { - throw new Error(res.error); - } + if (!res.ok) { + throw new Error(res.error); + } - const json = res.data; + return res.data.result; +} - if (json.error) { - throw new Error(json.error.message); - } +export async function updateProjectClient( + params: { + projectId: string; + teamId: string; + }, + body: Partial, +) { + type Response = { + result: Project; + }; - return json.data; - }, - onSuccess: () => { - return queryClient.invalidateQueries({ - queryKey: apiKeys.keys(address || ""), - }); + const res = await apiServerProxy({ + pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}`, + method: "PUT", + headers: { + "Content-Type": "application/json", }, + body: JSON.stringify(body), }); -} - -export function useRevokeApiKey() { - const address = useActiveAccount()?.address; - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (id: string) => { - type Result = { - data: ApiKey; - error?: { message: string }; - }; - - const res = await apiServerProxy({ - pathname: `/v1/keys/${id}/revoke`, - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - }); - if (!res.ok) { - throw new Error(res.error); - } + if (!res.ok) { + throw new Error(res.error); + } - const json = res.data; + return res.data.result; +} - if (json.error) { - throw new Error(json.error.message); - } +export async function deleteProjectClient(params: { + projectId: string; + teamId: string; +}) { + type Response = { + result: true; + }; - return json.data; - }, - onSuccess: () => { - return queryClient.invalidateQueries({ - queryKey: apiKeys.keys(address || ""), - }); - }, + const res = await apiServerProxy({ + pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}`, + method: "DELETE", }); -} -export const usePolicies = (serviceId?: string) => { - return useQuery({ - queryKey: ["policies", serviceId], - queryFn: async () => { - if (!serviceId) { - throw new Error(); - } - - type Result = { - data: ApiKeyServicePolicy; - error?: { message: string }; - }; + if (!res.ok) { + throw new Error(res.error); + } - const res = await apiServerProxy({ - pathname: "/v1/policies", - method: "GET", - headers: { - "Content-Type": "application/json", - }, - searchParams: { - serviceId, - }, - }); + return res.data.result; +} - if (!res.ok) { - throw new Error(res.error); - } +export type RotateSecretKeyAPIReturnType = { + data: { + secret: string; + secretMasked: string; + secretHash: string; + }; +}; - const json = res.data; - if (json.error) { - throw new Error(json.error.message); - } - return json.data; +export async function rotateSecretKeyClient(projectId: string) { + const res = await apiServerProxy({ + pathname: "/v2/keys/rotate-secret-key", + method: "POST", + body: JSON.stringify({ + projectId, + }), + headers: { + "Content-Type": "application/json", }, - enabled: !!serviceId, }); -}; - -export const useUpdatePolicies = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (input: { - serviceId: string; - data: ApiKeyServicePolicy; - }) => { - type Result = { - data: ApiKeyServicePolicy; - error?: { message: string }; - }; - const res = await apiServerProxy({ - pathname: "/v1/policies", - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - serviceId: input.serviceId, - data: input.data, - }), - }); - - if (!res.ok) { - throw new Error(res.error); - } + if (!res.ok) { + throw new Error(res.error); + } - const json = res.data; - if (json.error) { - throw new Error(json.error.message); - } - return json.data; - }, - onSuccess: (_, variables) => { - return queryClient.invalidateQueries({ - queryKey: ["policies", variables.serviceId], - }); - }, - }); -}; + return res.data; +} export function useRevokeAuthorizedWallet() { const address = useActiveAccount()?.address; diff --git a/apps/dashboard/src/app/account/components/AccountHeader.tsx b/apps/dashboard/src/app/account/components/AccountHeader.tsx index e596a59901d..2dba27098d5 100644 --- a/apps/dashboard/src/app/account/components/AccountHeader.tsx +++ b/apps/dashboard/src/app/account/components/AccountHeader.tsx @@ -8,7 +8,7 @@ import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; import { useCallback, useState } from "react"; import { useActiveWallet, useDisconnect } from "thirdweb/react"; -import { LazyCreateAPIKeyDialog } from "../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog"; +import { LazyCreateProjectDialog } from "../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog"; import { doLogout } from "../../login/auth-actions"; import { type AccountHeaderCompProps, @@ -61,27 +61,26 @@ export function AccountHeader(props: { - - setCreateProjectDialogState({ - isOpen: false, - }) - } - onCreateAndComplete={() => { - // refresh projects - router.refresh(); - }} - teamSlug={ - createProjectDialogState.isOpen - ? createProjectDialogState.team.slug - : undefined - } - enableNebulaServiceByDefault={ - createProjectDialogState.isOpen && - createProjectDialogState.team.enabledScopes.includes("nebula") - } - /> + {createProjectDialogState.isOpen && ( + + setCreateProjectDialogState({ + isOpen: false, + }) + } + onCreateAndComplete={() => { + // refresh projects + router.refresh(); + }} + teamId={createProjectDialogState.team.id} + teamSlug={createProjectDialogState.team.slug} + enableNebulaServiceByDefault={ + createProjectDialogState.isOpen && + createProjectDialogState.team.enabledScopes.includes("nebula") + } + /> + )} ); } diff --git a/apps/dashboard/src/app/account/overview/AccountTeamsUI.tsx b/apps/dashboard/src/app/account/overview/AccountTeamsUI.tsx index 3f891178292..acd60f27c19 100644 --- a/apps/dashboard/src/app/account/overview/AccountTeamsUI.tsx +++ b/apps/dashboard/src/app/account/overview/AccountTeamsUI.tsx @@ -108,7 +108,7 @@ function TeamRow(props: {
diff --git a/apps/dashboard/src/app/api/lib/getAPIKeys.ts b/apps/dashboard/src/app/api/lib/getAPIKeys.ts deleted file mode 100644 index c10e7d8e577..00000000000 --- a/apps/dashboard/src/app/api/lib/getAPIKeys.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { API_SERVER_URL } from "@/constants/env"; -import type { ApiKey } from "@3rdweb-sdk/react/hooks/useApi"; -import { getAuthToken } from "./getAuthToken"; - -// TODO - Fix the `/v1/keys/${apiKeyId}` endpoint in API server - -async function getAPIKey(apiKeyId: string) { - // The `/v1/keys/${apiKeyId}`; does not return the "FULL" ApiKey object for some reason - // Until this is fixed in API server - we just use the getApiKeys() and find the key by id - - const apiKeys = await getApiKeys(); - return apiKeys.find((key) => key.id === apiKeyId); - - // const authToken = getAuthToken(); - // const apiServerURL = new URL( - // process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com", - // ); - - // apiServerURL.pathname = `/v1/keys/${apiKeyId}`; - - // const res = await fetch(apiServerURL, { - // method: "GET", - // headers: { - // Authorization: `Bearer ${authToken}`, - // }, - // }); - - // const json = await res.json(); - - // if (json.error) { - // console.error(json.error); - // return undefined; - // } - - // return json.data as ApiKey; -} - -async function getApiKeys() { - const authToken = await getAuthToken(); - - const res = await fetch(`${API_SERVER_URL}/v1/keys`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - }); - const json = await res.json(); - - if (json.error) { - return []; - } - - return json.data as ApiKey[]; -} - -export function getAPIKeyForProjectId(projectId: string) { - if (projectId.startsWith("prj_")) { - return getAPIKey(projectId.slice("prj_".length)); - } - - return getAPIKey(projectId); -} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/projects/TeamProjectsPage.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/projects/TeamProjectsPage.tsx index 5fd0c305525..2a3881c7007 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/projects/TeamProjectsPage.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/projects/TeamProjectsPage.tsx @@ -24,7 +24,7 @@ import { SelectTrigger, } from "@/components/ui/select"; import { useDashboardRouter } from "@/lib/DashboardRouter"; -import { LazyCreateAPIKeyDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog"; +import { LazyCreateProjectDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog"; import { ChevronDownIcon, EllipsisVerticalIcon, @@ -167,10 +167,11 @@ export function TeamProjectsPage(props: {
)} - { // refresh projects router.refresh(); diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/page.tsx index a14b024caf8..7c092fa46cc 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/page.tsx @@ -16,7 +16,7 @@ export default async function Page(props: { const team = await getTeamBySlug(params.team_slug); if (!team) { - return redirect("/team"); + redirect("/team"); } return ( diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx index 6866d0a566f..e4a6f3cfaa1 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx @@ -156,12 +156,12 @@ function TeamSlugFormControl(props: { function TeamAvatarFormControl(props: { updateTeamImage: (file: File | undefined) => Promise; - avatar: string | undefined; + avatar: string | null; client: ThirdwebClient; }) { const teamAvatarUrl = resolveSchemeWithErrorHandler({ client: props.client, - uri: props.avatar, + uri: props.avatar || undefined, }); const [teamAvatar, setTeamAvatar] = useState(); diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/account-abstraction/AccountAbstractionPage.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/account-abstraction/AccountAbstractionPage.tsx index b5c5f0d02b1..a320aa5ad39 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/account-abstraction/AccountAbstractionPage.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/account-abstraction/AccountAbstractionPage.tsx @@ -2,14 +2,9 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { TabPathLinks } from "@/components/ui/tabs"; import { TrackedLinkTW } from "@/components/ui/tracked-link"; -import { - type ApiKeyService, - accountStatus, - useUserOpUsageAggregate, -} from "@3rdweb-sdk/react/hooks/useApi"; +import { useUserOpUsageAggregate } from "@3rdweb-sdk/react/hooks/useApi"; import { SmartWalletsBillingAlert } from "components/settings/ApiKeys/Alerts"; import { CircleAlertIcon } from "lucide-react"; -import { useMemo } from "react"; import { useActiveWalletChain } from "thirdweb/react"; import { AccountAbstractionSummary } from "../../../../../../components/smart-wallets/AccountAbstractionAnalytics/AccountAbstractionSummary"; import { AAFooterSection } from "./AAFooterSection"; @@ -21,22 +16,11 @@ export function AccountAbstractionLayout(props: { projectSlug: string; teamSlug: string; projectKey: string; - apiKeyServices: ApiKeyService[]; - billingStatus: "validPayment" | (string & {}) | null; children: React.ReactNode; + hasSmartWalletsWithoutBilling: boolean; }) { - const { apiKeyServices } = props; - const chain = useActiveWalletChain(); - const hasSmartWalletsWithoutBilling = useMemo(() => { - return apiKeyServices.find( - (s) => - props.billingStatus !== accountStatus.validPayment && - s.name === "bundler", - ); - }, [apiKeyServices, props.billingStatus]); - const isOpChain = chain?.id ? isOpChainId(chain.id) : false; const smartWalletsLayoutSlug = `/team/${props.teamSlug}/${props.projectSlug}/connect/account-abstraction`; @@ -65,7 +49,7 @@ export function AccountAbstractionLayout(props: {

- {hasSmartWalletsWithoutBilling ? ( + {props.hasSmartWalletsWithoutBilling ? ( ) : ( isOpChain && ( diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/account-abstraction/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/account-abstraction/layout.tsx index d7d25d38da4..b4250c02371 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/account-abstraction/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/account-abstraction/layout.tsx @@ -1,9 +1,8 @@ import { getProject } from "@/api/projects"; import { getTeamBySlug } from "@/api/team"; import type { Metadata } from "next"; -import { notFound, redirect } from "next/navigation"; +import { redirect } from "next/navigation"; import { getAbsoluteUrl } from "../../../../../../lib/vercel-utils"; -import { getAPIKeyForProjectId } from "../../../../../api/lib/getAPIKeys"; import { AccountAbstractionLayout } from "./AccountAbstractionPage"; export default async function Page(props: { @@ -22,22 +21,22 @@ export default async function Page(props: { } if (!project) { - redirect("/team"); + redirect(`/team/${team_slug}`); } - const apiKey = await getAPIKeyForProjectId(project.id); + const isBundlerServiceEnabled = !!project.services.find( + (s) => s.name === "bundler", + ); - if (!apiKey) { - notFound(); - } + const hasSmartWalletsWithoutBilling = + isBundlerServiceEnabled && team.billingStatus !== "validPayment"; return ( {props.children} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/account-abstraction/settings/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/account-abstraction/settings/page.tsx index a5469326bf2..15abe26c62b 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/account-abstraction/settings/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/account-abstraction/settings/page.tsx @@ -1,36 +1,57 @@ import { getProject } from "@/api/projects"; +import { getTeamBySlug } from "@/api/team"; import { ChakraProviderSetup } from "@/components/ChakraProviderSetup"; -import { notFound } from "next/navigation"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { CircleAlertIcon } from "lucide-react"; +import { redirect } from "next/navigation"; import { AccountAbstractionSettingsPage } from "../../../../../../../components/smart-wallets/SponsorshipPolicies"; -import { getValidAccount } from "../../../../../../account/settings/getAccount"; -import { getAPIKeyForProjectId } from "../../../../../../api/lib/getAPIKeys"; +import { getValidTeamPlan } from "../../../../../components/TeamHeader/getValidTeamPlan"; export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string }>; }) { const { team_slug, project_slug } = await props.params; - const [account, project] = await Promise.all([ - getValidAccount(), + const [project, team] = await Promise.all([ getProject(team_slug, project_slug), + getTeamBySlug(team_slug), ]); + if (!team) { + redirect("/team"); + } + if (!project) { - notFound(); + redirect(`/team/${team_slug}`); } - const apiKey = await getAPIKeyForProjectId(project.id); + const bundlerService = project.services.find((s) => s.name === "bundler"); - if (!apiKey) { - notFound(); + if (!bundlerService) { + return ( + + + Account Abstraction service is disabled + + Enable Account Abstraction service in{" "} + + project settings + {" "} + to configure the sponsorship rules + + + ); } return ( ); diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/in-app-wallets/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/in-app-wallets/layout.tsx index 7415baefc07..ab61df4bc07 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/in-app-wallets/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/in-app-wallets/layout.tsx @@ -1,7 +1,6 @@ import { getProject } from "@/api/projects"; -import { getAPIKeyForProjectId } from "app/api/lib/getAPIKeys"; -import { notFound } from "next/navigation"; -import { TabPathLinks } from "../../../../../../@/components/ui/tabs"; +import { TabPathLinks } from "@/components/ui/tabs"; +import { redirect } from "next/navigation"; import { InAppWalletFooterSection } from "./_components/footer"; import { InAppWalletsHeader } from "./_components/header"; import { TRACKING_CATEGORY } from "./_constants"; @@ -13,24 +12,18 @@ export default async function Layout(props: { }>; children: React.ReactNode; }) { - const project = await getProject( - (await props.params).team_slug, - (await props.params).project_slug, - ); - if (!project) { - notFound(); - } + const params = await props.params; + const project = await getProject(params.team_slug, params.project_slug); - const apiKey = await getAPIKeyForProjectId(project.id); - if (!apiKey) { - notFound(); + if (!project) { + redirect(`/team/${params.team_slug}`); } - const { team_slug, project_slug } = await props.params; + const { team_slug, project_slug } = params; return (
- +
; @@ -13,8 +12,11 @@ export default async function Page(props: { interval?: string; }>; }) { - const searchParams = await props.searchParams; - const params = await props.params; + const [searchParams, params] = await Promise.all([ + props.searchParams, + props.params, + ]); + const range = searchParams.from && searchParams.to ? { @@ -31,18 +33,14 @@ export default async function Page(props: { : "week"; const project = await getProject(params.team_slug, params.project_slug); - if (!project) { - notFound(); - } - const apiKey = await getAPIKeyForProjectId(project.id); - if (!apiKey) { - notFound(); + if (!project) { + redirect(`/team/${params.team_slug}`); } return ( diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/in-app-wallets/settings/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/in-app-wallets/settings/page.tsx index b9a63aa003f..11968dcc72e 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/in-app-wallets/settings/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/in-app-wallets/settings/page.tsx @@ -1,35 +1,34 @@ import { getProject } from "@/api/projects"; -import { notFound, redirect } from "next/navigation"; +import { getTeamBySlug } from "@/api/team"; +import { redirect } from "next/navigation"; import { InAppWalletSettingsPage } from "../../../../../../../components/embedded-wallets/Configure"; -import { getValidAccount } from "../../../../../../account/settings/getAccount"; -import { getAPIKeyForProjectId } from "../../../../../../api/lib/getAPIKeys"; +import { getValidTeamPlan } from "../../../../../components/TeamHeader/getValidTeamPlan"; export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string }>; }) { const { team_slug, project_slug } = await props.params; - const [account, project] = await Promise.all([ - getValidAccount(), + const [team, project] = await Promise.all([ + getTeamBySlug(team_slug), getProject(team_slug, project_slug), ]); - if (!project) { + if (!team) { redirect("/team"); } - const apiKey = await getAPIKeyForProjectId(project.id); - - if (!apiKey) { - // unexpected error - this should never happen - notFound(); + if (!project) { + redirect(`/team/${team_slug}`); } return ( ); } diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/in-app-wallets/users/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/in-app-wallets/users/page.tsx index 612d6980d19..1e165bca55d 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/in-app-wallets/users/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/in-app-wallets/users/page.tsx @@ -21,7 +21,7 @@ export default async function Page(props: { } if (!project) { - redirect("/team"); + redirect(`/team/${params.team_slug}`); } return ( diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/layout.tsx index b0525234027..d3dae20f557 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/layout.tsx @@ -1,7 +1,7 @@ import { getProject } from "@/api/projects"; import { TabPathLinks } from "@/components/ui/tabs"; import Link from "next/link"; -import { notFound } from "next/navigation"; +import { redirect } from "next/navigation"; export default async function Layout(props: { params: Promise<{ @@ -14,7 +14,7 @@ export default async function Layout(props: { const project = await getProject(params.team_slug, params.project_slug); if (!project) { - notFound(); + redirect(`/team/${params.team_slug}`); } const payLayoutPath = `/team/${params.team_slug}/${params.project_slug}/connect/pay`; diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/page.tsx index f33c5cd6bb2..4f945d1bf72 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/page.tsx @@ -1,5 +1,5 @@ import { getProject } from "@/api/projects"; -import { notFound } from "next/navigation"; +import { redirect } from "next/navigation"; import { PayAnalytics } from "../../../../../../components/pay/PayAnalytics/PayAnalytics"; export default async function Page(props: { @@ -8,13 +8,11 @@ export default async function Page(props: { project_slug: string; }>; }) { - const project = await getProject( - (await props.params).team_slug, - (await props.params).project_slug, - ); + const params = await props.params; + const project = await getProject(params.team_slug, params.project_slug); if (!project) { - notFound(); + redirect(`/team/${params.team_slug}`); } return ( diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/settings/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/settings/page.tsx index 298d6597b73..1b7b793aba7 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/settings/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/settings/page.tsx @@ -1,7 +1,7 @@ import { getProject } from "@/api/projects"; -import { notFound } from "next/navigation"; +import { getTeamBySlug } from "@/api/team"; +import { redirect } from "next/navigation"; import { PayConfig } from "../../../../../../../components/pay/PayConfig"; -import { getAPIKeyForProjectId } from "../../../../../../api/lib/getAPIKeys"; export default async function Page(props: { params: Promise<{ @@ -10,17 +10,19 @@ export default async function Page(props: { }>; }) { const { team_slug, project_slug } = await props.params; - const project = await getProject(team_slug, project_slug); - if (!project) { - notFound(); - } + const [project, team] = await Promise.all([ + getProject(team_slug, project_slug), + getTeamBySlug(team_slug), + ]); - const apiKey = await getAPIKeyForProjectId(project.id); + if (!team) { + redirect("/team"); + } - if (!apiKey) { - notFound(); + if (!project) { + redirect(`/team/${team_slug}`); } - return ; + return ; } diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/webhooks/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/webhooks/page.tsx index a7c16cc5684..824f6310ff3 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/webhooks/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/pay/webhooks/page.tsx @@ -1,5 +1,5 @@ import { getProject } from "@/api/projects"; -import { notFound } from "next/navigation"; +import { redirect } from "next/navigation"; import { PayWebhooksPage } from "./components/webhooks.client"; export default async function Page(props: { @@ -8,13 +8,11 @@ export default async function Page(props: { project_slug: string; }>; }) { - const project = await getProject( - (await props.params).team_slug, - (await props.params).project_slug, - ); + const params = await props.params; + const project = await getProject(params.team_slug, params.project_slug); if (!project) { - notFound(); + redirect(`/team/${params.team_slug}`); } return ( diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx index af6f6ac8027..b281cd8617b 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx @@ -1,6 +1,6 @@ import { getProjects } from "@/api/projects"; import { getTeams } from "@/api/team"; -import { notFound, redirect } from "next/navigation"; +import { redirect } from "next/navigation"; import { getValidAccount } from "../../../account/settings/getAccount"; import { getAuthTokenWalletAddress } from "../../../api/lib/getAuthToken"; import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client"; @@ -27,8 +27,7 @@ export default async function TeamLayout(props: { ); if (!team) { - // not a valid team, redirect back to 404 - notFound(); + redirect("/team"); } const teamsAndProjects = await Promise.all( diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx index 77614ed0c28..95f17015504 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx @@ -1,6 +1,6 @@ import { type Project, getProject } from "@/api/projects"; import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage"; -import { notFound } from "next/navigation"; +import { redirect } from "next/navigation"; import { type DurationId, @@ -68,7 +68,7 @@ export default async function ProjectOverviewPage(props: PageProps) { }; if (!project) { - notFound(); + redirect(`/team/${params.team_slug}`); } const isActive = await isProjectActive({ clientId: project.publishableKey }); diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.stories.tsx index af88097f2de..1920edab966 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.stories.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.stories.tsx @@ -1,8 +1,6 @@ -import type { UpdateKeyInput } from "@3rdweb-sdk/react/hooks/useApi"; import type { Meta, StoryObj } from "@storybook/react"; -import { useMutation } from "@tanstack/react-query"; import { Toaster } from "sonner"; -import { createApiKeyStub } from "../../../../../stories/stubs"; +import { projectStub } from "../../../../../stories/stubs"; import { mobileViewport } from "../../../../../stories/utils"; import { ProjectGeneralSettingsPageUI } from "./ProjectGeneralSettingsPage"; @@ -30,35 +28,21 @@ export const Mobile: Story = { }, }; -const apiKeyStub = createApiKeyStub(); -apiKeyStub.secret = undefined; - function Story() { - const updateMutation = useMutation({ - mutationFn: async (inputs: UpdateKeyInput) => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - console.log("update with", inputs); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: async (id: string) => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - console.log("delete with", id); - }, - }); return (
{ + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log("updateProject", params); + return projectStub("foo", "team-1"); + }} + deleteProject={async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log("deleteProject"); }} + project={projectStub("foo", "team-1")} + teamSlug="foo" onKeyUpdated={undefined} rotateSecretKey={async () => { await new Promise((resolve) => setTimeout(resolve, 1000)); diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.tsx index 441cdf9f563..0ed74c41bc1 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.tsx @@ -1,6 +1,5 @@ "use client"; - -import { apiServerProxy } from "@/actions/proxies"; +import type { Project } from "@/api/projects"; import { DangerSettingCard } from "@/components/blocks/DangerSettingCard"; import { SettingsCard } from "@/components/blocks/SettingsCard"; import { CopyTextButton } from "@/components/ui/CopyTextButton"; @@ -24,13 +23,15 @@ import { Textarea } from "@/components/ui/textarea"; import { ToolTipLabel } from "@/components/ui/tooltip"; import { useDashboardRouter } from "@/lib/DashboardRouter"; import { cn } from "@/lib/utils"; -import type { ApiKey, UpdateKeyInput } from "@3rdweb-sdk/react/hooks/useApi"; +import type { RotateSecretKeyAPIReturnType } from "@3rdweb-sdk/react/hooks/useApi"; import { - useRevokeApiKey, - useUpdateApiKey, + deleteProjectClient, + rotateSecretKeyClient, + updateProjectClient, } from "@3rdweb-sdk/react/hooks/useApi"; import { zodResolver } from "@hookform/resolvers/zod"; -import { type UseMutationResult, useMutation } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; +import type { ProjectService } from "@thirdweb-dev/service-utils"; import { SERVICES } from "@thirdweb-dev/service-utils"; import { type ServiceName, @@ -48,199 +49,210 @@ import { useState } from "react"; import { type UseFormReturn, useForm } from "react-hook-form"; import { type FieldArrayWithId, useFieldArray } from "react-hook-form"; import { toast } from "sonner"; +import { RE_BUNDLE_ID } from "utils/regex"; import { joinWithComma, toArrFromList } from "utils/string"; +import { validStrList } from "utils/validations"; +import { z } from "zod"; import { HIDDEN_SERVICES, - type ProjectSettingsPageFormSchema, - projectSettingsPageFormSchema, + projectDomainsSchema, + projectNameSchema, } from "../../../../../components/settings/ApiKeys/validations"; -type EditProjectUIPaths = { +// TODO: instead of single submit handler, move the submit to each section + +const projectSettingsFormSchema = z.object({ + name: projectNameSchema, + domains: projectDomainsSchema, + servicesMeta: z.array( + z.object({ + name: z.string(), + enabled: z.boolean(), + actions: z.array(z.string()), + }), + ), + bundleIds: z.string().refine((str) => validStrList(str, RE_BUNDLE_ID), { + message: "Some of the bundle ids are invalid", + }), +}); + +type ProjectSettingsPageFormSchema = z.infer; + +type ProjectSettingPaths = { inAppConfig: string; aaConfig: string; payConfig: string; afterDeleteRedirectTo: string; }; -type RotateSecretKeyAPIReturnType = { - data: { - secret: string; - secretMasked: string; - secretHash: string; - }; -}; - export function ProjectGeneralSettingsPage(props: { - apiKey: ApiKey; - paths: EditProjectUIPaths; - onKeyUpdated: (() => void) | undefined; + project: Project; + teamSlug: string; showNebulaSettings: boolean; - projectId: string; }) { - const updateMutation = useUpdateApiKey(); - const deleteMutation = useRevokeApiKey(); + const router = useDashboardRouter(); return ( { - const res = await apiServerProxy({ - pathname: "/v2/keys/rotate-secret-key", - method: "POST", - body: JSON.stringify({ - projectId: props.projectId, - }), - headers: { - "Content-Type": "application/json", + teamSlug={props.teamSlug} + project={props.project} + updateProject={async (projectValues) => { + return updateProjectClient( + { + projectId: props.project.id, + teamId: props.project.teamId, }, + projectValues, + ); + }} + deleteProject={async () => { + await deleteProjectClient({ + projectId: props.project.id, + teamId: props.project.teamId, }); - - if (!res.ok) { - throw new Error(res.error); - } - - return res.data; + }} + onKeyUpdated={() => { + router.refresh(); + }} + showNebulaSettings={props.showNebulaSettings} + rotateSecretKey={async () => { + return rotateSecretKeyClient(props.project.id); }} /> ); } -type UpdateMutation = UseMutationResult< - unknown, - unknown, - UpdateKeyInput, - unknown ->; - -type DeleteMutation = UseMutationResult; +type UpdateProject = (project: Partial) => Promise; +type DeleteProject = () => Promise; +type RotateSecretKey = () => Promise; +type UpdateAPIForm = UseFormReturn; -interface EditApiKeyProps { - apiKey: ApiKey; - updateMutation: UpdateMutation; - deleteMutation: DeleteMutation; - paths: EditProjectUIPaths; +export function ProjectGeneralSettingsPageUI(props: { + project: Project; + updateProject: UpdateProject; + deleteProject: DeleteProject; onKeyUpdated: (() => void) | undefined; showNebulaSettings: boolean; - rotateSecretKey: () => Promise; -} + rotateSecretKey: RotateSecretKey; + teamSlug: string; +}) { + const projectLayout = `/team/${props.teamSlug}/${props.project.slug}`; -type UpdateAPIForm = UseFormReturn; + const paths = { + aaConfig: `${projectLayout}/connect/account-abstraction/settings`, + inAppConfig: `${projectLayout}/connect/in-app-wallets/settings`, + payConfig: `${projectLayout}/connect/pay/settings`, + afterDeleteRedirectTo: `/team/${props.teamSlug}`, + }; -export const ProjectGeneralSettingsPageUI: React.FC = ( - props, -) => { - const { apiKey, updateMutation, deleteMutation } = props; + const { project } = props; const trackEvent = useTrack(); const router = useDashboardRouter(); + const updateProject = useMutation({ + mutationFn: props.updateProject, + }); + const form = useForm({ - resolver: zodResolver(projectSettingsPageFormSchema), + resolver: zodResolver(projectSettingsFormSchema), defaultValues: { - name: apiKey.name, - domains: joinWithComma(apiKey.domains), - bundleIds: joinWithComma(apiKey.bundleIds), - redirectUrls: joinWithComma(apiKey.redirectUrls), - services: SERVICES.map((srv) => { - const existingService = (apiKey.services || []).find( - (s) => s.name === srv.name, + name: project.name, + domains: joinWithComma(project.domains), + bundleIds: joinWithComma(project.bundleIds), + servicesMeta: SERVICES.map((service) => { + const projectService = project.services.find( + (projectService) => projectService.name === service.name, ); return { - name: srv.name, - targetAddresses: existingService - ? joinWithComma(existingService.targetAddresses) - : "", - enabled: !!existingService, - actions: existingService?.actions || [], - recoveryShareManagement: existingService?.recoveryShareManagement, - customAuthentication: existingService?.customAuthentication, - customAuthEndpoint: existingService?.customAuthEndpoint, - applicationName: existingService?.applicationName, - applicationImageUrl: existingService?.applicationImageUrl, + name: service.name as ServiceName, + enabled: !!projectService, + actions: projectService?.actions || [], }; }), }, }); const handleSubmit = form.handleSubmit((values) => { - const enabledServices = (values.services || []).filter( - (srv) => !!srv.enabled, - ); + const services: ProjectService[] = []; + + for (const serviceMeta of values.servicesMeta) { + if (serviceMeta.enabled) { + function getBaseService(): ProjectService { + const projectService = project.services.find( + (s) => s.name === serviceMeta.name, + ); + + if (projectService) { + return projectService; + } + + if (serviceMeta.name === "pay") { + return { + name: "pay", + payoutAddress: null, + actions: [], + }; + } + + return { + name: serviceMeta.name as Exclude, + actions: [], + }; + } - if (enabledServices.length > 0) { - // validate embedded wallets custom auth - const embeddedWallets = enabledServices.find( - (s) => s.name === "embeddedWallets", - ); - - if (embeddedWallets) { - const { customAuthentication, recoveryShareManagement } = - embeddedWallets; - - if ( - recoveryShareManagement === "USER_MANAGED" && - (!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.", - }); + const serviceToAdd = getBaseService(); + + // add the actions changes to the base service + if (serviceMeta.name === "storage") { + serviceToAdd.actions = serviceMeta.actions as ("read" | "write")[]; + services.push(serviceToAdd); } - } - const formattedValues = { - id: apiKey.id, - name: values.name, - domains: toArrFromList(values.domains), - bundleIds: toArrFromList(values.bundleIds), - redirectUrls: toArrFromList(values.redirectUrls, true), - services: (values.services || []) - .filter((srv) => srv.enabled) - // FIXME: Not yet supported, add when it is - // eslint-disable-next-line @typescript-eslint/no-unused-vars - .map(({ recoveryShareManagement, ...srv }) => ({ - ...srv, - targetAddresses: toArrFromList(srv.targetAddresses), - })), - }; - - trackEvent({ - category: "api-keys", - action: "edit", - label: "attempt", - }); + services.push(serviceToAdd); + } + } - updateMutation.mutate(formattedValues, { - onSuccess: () => { - toast.success("Project updated successfully"); - trackEvent({ - category: "api-keys", - action: "edit", - label: "success", - }); - - props.onKeyUpdated?.(); - }, - onError: (err) => { - toast.error("Failed to update project"); - trackEvent({ - category: "api-keys", - action: "edit", - label: "error", - error: err, - }); - }, - }); - } else { - toast.error("Service not selected", { - description: "Choose at least one service", + if (services.length === 0) { + return toast.error("No services selected", { + description: "Please select at least one service", }); } + + const projectValues: Partial = { + id: project.id, + name: values.name, + domains: toArrFromList(values.domains), + bundleIds: toArrFromList(values.bundleIds), + services, + }; + + trackEvent({ + category: "api-keys", + action: "edit", + label: "attempt", + }); + + updateProject.mutate(projectValues, { + onSuccess: () => { + toast.success("Project updated successfully"); + trackEvent({ + category: "api-keys", + action: "edit", + label: "success", + }); + + props.onKeyUpdated?.(); + }, + onError: (err) => { + toast.error("Failed to update project"); + trackEvent({ + category: "api-keys", + action: "edit", + label: "error", + error: err, + }); + }, + }); }); return ( @@ -255,56 +267,54 @@ export const ProjectGeneralSettingsPageUI: React.FC = (
- { - router.replace(props.paths.afterDeleteRedirectTo); + router.replace(paths.afterDeleteRedirectTo); }} />
); -}; +} function ProjectNameSetting(props: { form: UpdateAPIForm; - updateMutation: UpdateMutation; + isUpdatingProject: boolean; handleSubmit: () => void; }) { - const { form, updateMutation, handleSubmit } = props; + const { form, handleSubmit } = props; const isNameDirty = form.getFieldState("name").isDirty; return ( @@ -319,7 +329,7 @@ function ProjectNameSetting(props: { saveButton={{ onClick: handleSubmit, disabled: !isNameDirty, - isPending: updateMutation.isPending && isNameDirty, + isPending: props.isUpdatingProject && isNameDirty, }} bottomText="Please use 64 characters at maximum" > @@ -336,10 +346,10 @@ function ProjectNameSetting(props: { function AllowedDomainsSetting(props: { form: UpdateAPIForm; - updateMutation: UpdateMutation; + isUpdatingProject: boolean; handleSubmit: () => void; }) { - const { form, handleSubmit, updateMutation } = props; + const { form, handleSubmit } = props; const isDomainsDirty = form.getFieldState("domains").isDirty; const helperText = ( @@ -384,7 +394,7 @@ function AllowedDomainsSetting(props: { saveButton={{ onClick: handleSubmit, disabled: !isDomainsDirty, - isPending: updateMutation.isPending && isDomainsDirty, + isPending: props.isUpdatingProject && isDomainsDirty, }} bottomText="This is only applicable for web applications" > @@ -441,17 +451,17 @@ function AllowedDomainsSetting(props: { function AllowedBundleIDsSetting(props: { form: UpdateAPIForm; - updateMutation: UpdateMutation; + isUpdatingProject: boolean; handleSubmit: () => void; }) { - const { form, handleSubmit, updateMutation } = props; + const { form, handleSubmit } = props; const isBundleIdsDirty = form.getFieldState("bundleIds").isDirty; return ( void; - apiKey: ApiKey; - paths: EditApiKeyProps["paths"]; + paths: ProjectSettingPaths; showNebulaSettings: boolean; }) { - const { form, handleSubmit, updateMutation } = props; + const { form, handleSubmit } = props; - const { fields, update } = useFieldArray({ + const formFields = useFieldArray({ control: form.control, - name: "services", + name: "servicesMeta", }); - const handleAction = ( + + const toggleServiceAction = ( srvIdx: number, - srv: FieldArrayWithId, + srv: FieldArrayWithId, actionName: string, checked: boolean, ) => { @@ -538,7 +548,7 @@ function EnabledServicesSetting(props: { ? [...(srv.actions || []), actionName] : (srv.actions || []).filter((a) => a !== actionName); - update(srvIdx, { + formFields.update(srvIdx, { ...srv, actions, }); @@ -555,19 +565,25 @@ function EnabledServicesSetting(props: { saveButton={{ onClick: handleSubmit, disabled: !form.formState.isDirty, - isPending: updateMutation.isPending, + isPending: props.isUpdatingProject, }} bottomText="" >
- {fields.map((srv, idx) => { - const service = getServiceByName(srv.name as ServiceName); + {formFields.fields.map((service, idx) => { + const serviceDefinition = getServiceByName( + service.name as ServiceName, + ); + const hidden = - (service.name === "nebula" && !props.showNebulaSettings) || - HIDDEN_SERVICES.includes(service.name); + (serviceDefinition.name === "nebula" && + !props.showNebulaSettings) || + HIDDEN_SERVICES.includes(serviceDefinition.name); - const serviceName = getServiceByName(service.name as ServiceName); + const serviceName = getServiceByName( + serviceDefinition.name as ServiceName, + ); const shouldShow = !hidden && serviceName; if (!shouldShow) { @@ -575,25 +591,33 @@ function EnabledServicesSetting(props: { } let configurationLink: string | undefined; - if (service.name === "embeddedWallets" && srv.enabled) { + if ( + serviceDefinition.name === "embeddedWallets" && + service.enabled + ) { configurationLink = props.paths.inAppConfig; - } else if (service.name === "bundler" && srv.enabled) { + } else if ( + serviceDefinition.name === "bundler" && + service.enabled + ) { configurationLink = props.paths.aaConfig; - } else if (service.name === "pay") { + } else if (serviceDefinition.name === "pay" && service.enabled) { configurationLink = props.paths.payConfig; } return (
{/* Left */}
-

{service.title}

+

+ {serviceDefinition.title} +

- {service.description} + {serviceDefinition.description}

@@ -613,36 +637,43 @@ function EnabledServicesSetting(props: {
)} - {service.actions.length > 0 && ( + {serviceDefinition.actions.length > 0 && (
- {service.actions.map((sa) => ( - -
- - - handleAction(idx, srv, sa.name, !!checked) - } - /> - {sa.title} - -
-
- ))} + {serviceDefinition.actions.map((sa) => { + return ( + +
+ + + toggleServiceAction( + idx, + service, + sa.name, + !!checked, + ) + } + /> + {sa.title} + +
+
+ ); + })}
)}
{/* Right */} - update(idx, { - ...srv, + checked={service.enabled} + onCheckedChange={(v) => { + return formFields.update(idx, { + ...service, enabled: !!v, - }) - } + }); + }} />
); @@ -653,15 +684,19 @@ function EnabledServicesSetting(props: { ); } -function APIKeyDetails({ - apiKey, +function ProjectKeyDetails({ + project, rotateSecretKey, }: { - rotateSecretKey: () => Promise; - apiKey: ApiKey; + rotateSecretKey: RotateSecretKey; + project: Project; }) { - const { createdAt, updatedAt, lastAccessedAt } = apiKey; - const [secretKeyMasked, setSecretKeyMasked] = useState(apiKey.secretMasked); + // currently only showing the first secret key + const { createdAt, updatedAt, lastAccessedAt } = project; + const [secretKeyMasked, setSecretKeyMasked] = useState( + project.secretKeys[0]?.masked, + ); + const clientId = project.publishableKey; return (
@@ -672,9 +707,9 @@ function APIKeyDetails({

@@ -706,9 +741,17 @@ function APIKeyDetails({ )}
- - - + + +
); @@ -716,27 +759,32 @@ function APIKeyDetails({ function TimeInfo(props: { label: string; - date: string | undefined; + date: string | null; + fallbackText: string; }) { return (

{props.label}

- {props.date ? format(new Date(props.date), "MMMM dd, yyyy") : "Never"} + {props.date + ? format(new Date(props.date), "MMMM dd, yyyy") + : props.fallbackText}

); } function DeleteProject(props: { - id: string; - name: string; - deleteMutation: UseMutationResult; + projectName: string; + deleteProject: DeleteProject; onDeleteSuccessful: () => void; }) { - const { id, name, deleteMutation, onDeleteSuccessful } = props; const trackEvent = useTrack(); + const deleteProject = useMutation({ + mutationFn: props.deleteProject, + }); + const handleRevoke = () => { trackEvent({ category: "api-keys", @@ -744,10 +792,10 @@ function DeleteProject(props: { label: "attempt", }); - deleteMutation.mutate(id, { + deleteProject.mutate(undefined, { onSuccess: () => { toast.success("Project deleted successfully"); - onDeleteSuccessful(); + props.onDeleteSuccessful(); trackEvent({ category: "api-keys", action: "revoke", @@ -775,18 +823,18 @@ function DeleteProject(props: { buttonOnClick={() => handleRevoke()} buttonLabel="Delete project" confirmationDialog={{ - title: `Delete project "${name}"?`, + title: `Delete project "${props.projectName}"?`, description: description, }} description={description} - isPending={deleteMutation.isPending} + isPending={deleteProject.isPending} title="Delete Project" /> ); } function RotateSecretKeyButton(props: { - rotateSecretKey: () => Promise; + rotateSecretKey: RotateSecretKey; onSuccess: (data: RotateSecretKeyAPIReturnType) => void; }) { const [isOpen, setIsOpen] = useState(false); @@ -835,7 +883,7 @@ type RotateSecretKeyScreen = | { id: "save-newkey"; secretKey: string }; function RotateSecretKeyModalContent(props: { - rotateSecretKey: () => Promise; + rotateSecretKey: RotateSecretKey; closeModal: () => void; disableModalClose: () => void; onSuccess: (data: RotateSecretKeyAPIReturnType) => void; @@ -871,7 +919,7 @@ function RotateSecretKeyModalContent(props: { } function RotateSecretKeyInitialScreen(props: { - rotateSecretKey: () => Promise; + rotateSecretKey: RotateSecretKey; onSuccess: (data: RotateSecretKeyAPIReturnType) => void; closeModal: () => void; }) { diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPageForTeams.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPageForTeams.tsx deleted file mode 100644 index 171a7acdede..00000000000 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPageForTeams.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import type { Team } from "@/api/team"; -import { useDashboardRouter } from "@/lib/DashboardRouter"; -import type { ApiKey } from "@3rdweb-sdk/react/hooks/useApi"; -import { ProjectGeneralSettingsPage } from "./ProjectGeneralSettingsPage"; - -export function ProjectGeneralSettingsPageForTeams(props: { - team: Team; - project_slug: string; - apiKey: ApiKey; - projectId: string; -}) { - const router = useDashboardRouter(); - const { team, project_slug, apiKey, projectId } = props; - const projectLayout = `/team/${team.slug}/${project_slug}`; - - // TODO - add a Project Image form field on this page - - return ( - { - router.refresh(); - }} - showNebulaSettings={team.enabledScopes.includes("nebula")} - /> - ); -} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/page.tsx index 2427cc96ede..a577312c9ae 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/page.tsx @@ -1,38 +1,31 @@ import { getProject } from "@/api/projects"; import { getTeamBySlug } from "@/api/team"; -import { notFound, redirect } from "next/navigation"; -import { getAPIKeyForProjectId } from "../../../../api/lib/getAPIKeys"; -import { ProjectGeneralSettingsPageForTeams } from "./ProjectGeneralSettingsPageForTeams"; +import { redirect } from "next/navigation"; +import { ProjectGeneralSettingsPage } from "./ProjectGeneralSettingsPage"; export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string }>; }) { const { team_slug, project_slug } = await props.params; - const team = await getTeamBySlug(team_slug); + const [team, project] = await Promise.all([ + getTeamBySlug(team_slug), + getProject(team_slug, project_slug), + ]); if (!team) { redirect("/team"); } - const project = await getProject(team_slug, project_slug); - if (!project) { - notFound(); - } - - const apiKey = await getAPIKeyForProjectId(project.id); - - if (!apiKey) { - notFound(); + redirect(`/team/${team_slug}`); } return ( - ); } diff --git a/apps/dashboard/src/app/team/components/TeamHeader/team-header-logged-in.client.tsx b/apps/dashboard/src/app/team/components/TeamHeader/team-header-logged-in.client.tsx index 4ffef4b7de2..b3a586894b9 100644 --- a/apps/dashboard/src/app/team/components/TeamHeader/team-header-logged-in.client.tsx +++ b/apps/dashboard/src/app/team/components/TeamHeader/team-header-logged-in.client.tsx @@ -8,7 +8,7 @@ import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; import { useCallback, useState } from "react"; import { useActiveWallet, useDisconnect } from "thirdweb/react"; -import { LazyCreateAPIKeyDialog } from "../../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog"; +import { LazyCreateProjectDialog } from "../../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog"; import { doLogout } from "../../../login/auth-actions"; import { type TeamHeaderCompProps, @@ -65,27 +65,26 @@ export function TeamHeaderLoggedIn(props: { - - setCreateProjectDialogState({ - isOpen: false, - }) - } - onCreateAndComplete={() => { - // refresh projects - router.refresh(); - }} - enableNebulaServiceByDefault={ - createProjectDialogState.isOpen && - createProjectDialogState.team.enabledScopes.includes("nebula") - } - /> + {createProjectDialogState.isOpen && ( + + setCreateProjectDialogState({ + isOpen: false, + }) + } + onCreateAndComplete={() => { + // refresh projects + router.refresh(); + }} + enableNebulaServiceByDefault={ + createProjectDialogState.isOpen && + createProjectDialogState.team.enabledScopes.includes("nebula") + } + /> + )}
); } diff --git a/apps/dashboard/src/components/embedded-wallets/Configure/InAppWalletSettingsUI.stories.tsx b/apps/dashboard/src/components/embedded-wallets/Configure/InAppWalletSettingsUI.stories.tsx index 4451e49fa2f..a09a1842846 100644 --- a/apps/dashboard/src/components/embedded-wallets/Configure/InAppWalletSettingsUI.stories.tsx +++ b/apps/dashboard/src/components/embedded-wallets/Configure/InAppWalletSettingsUI.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { accountStub, createApiKeyStub } from "../../../stories/stubs"; +import { projectStub } from "../../../stories/stubs"; import { mobileViewport } from "../../../stories/utils"; import { InAppWalletSettingsUI } from "./index"; @@ -46,8 +46,6 @@ export const FreePlanMobile: Story = { }, }; -const apiKeyStub = createApiKeyStub(); - function Variants(props: { canEditAdvancedFeatures: boolean; }) { @@ -56,11 +54,16 @@ function Variants(props: {
{}} - twAccount={accountStub()} />
diff --git a/apps/dashboard/src/components/embedded-wallets/Configure/index.tsx b/apps/dashboard/src/components/embedded-wallets/Configure/index.tsx index 6fd43deeaab..aa2ca66a4d2 100644 --- a/apps/dashboard/src/components/embedded-wallets/Configure/index.tsx +++ b/apps/dashboard/src/components/embedded-wallets/Configure/index.tsx @@ -1,7 +1,10 @@ "use client"; +import type { Project } from "@/api/projects"; import { DynamicHeight } from "@/components/ui/DynamicHeight"; import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Form, @@ -17,33 +20,29 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { TrackedLinkTW } from "@/components/ui/tracked-link"; import { cn } from "@/lib/utils"; -import { - type Account, - type ApiKey, - type ApiKeyService, - type UpdateKeyInput, - useUpdateApiKey, -} from "@3rdweb-sdk/react/hooks/useApi"; +import { updateProjectClient } from "@3rdweb-sdk/react/hooks/useApi"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import type { ProjectEmbeddedWalletsService } from "@thirdweb-dev/service-utils"; import { GatedSwitch } from "components/settings/Account/Billing/GatedSwitch"; import { type ApiKeyEmbeddedWalletsValidationSchema, apiKeyEmbeddedWalletsValidationSchema, } from "components/settings/ApiKeys/validations"; import { useTrack } from "hooks/analytics/useTrack"; -import { PlusIcon, Trash2Icon } from "lucide-react"; +import { CircleAlertIcon, PlusIcon, Trash2Icon } from "lucide-react"; import type React from "react"; import { type UseFormReturn, useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; import { toArrFromList } from "utils/string"; +import type { Team } from "../../../@/api/team"; type InAppWalletSettingsPageProps = { - apiKey: Pick< - ApiKey, - "id" | "name" | "domains" | "bundleIds" | "services" | "redirectUrls" - >; trackingCategory: string; - twAccount: Account; + project: Project; + teamId: string; + teamSlug: string; + validTeamPlan: Team["billingPlan"]; }; const TRACKING_CATEGORY = "embedded-wallet"; @@ -55,12 +54,23 @@ type UpdateAPIKeyTrackingData = { }; export function InAppWalletSettingsPage(props: InAppWalletSettingsPageProps) { - const mutation = useUpdateApiKey(); + const updateProject = useMutation({ + mutationFn: async (projectValues: Partial) => { + await updateProjectClient( + { + projectId: props.project.id, + teamId: props.teamId, + }, + projectValues, + ); + }, + }); + const { trackingCategory } = props; const trackEvent = useTrack(); - function handleAPIKeyUpdate( - newValue: UpdateKeyInput, + function handleUpdateProject( + projectValues: Partial, trackingData: UpdateAPIKeyTrackingData, ) { trackEvent({ @@ -69,7 +79,7 @@ export function InAppWalletSettingsPage(props: InAppWalletSettingsPageProps) { label: "attempt", }); - mutation.mutate(newValue, { + updateProject.mutate(projectValues, { onSuccess: () => { toast.success("In-App Wallet API Key configuration updated"); trackEvent({ @@ -93,50 +103,88 @@ export function InAppWalletSettingsPage(props: InAppWalletSettingsPageProps) { } return ( - ); } -export const InAppWalletSettingsUI: React.FC< +const InAppWalletSettingsPageUI: React.FC< InAppWalletSettingsPageProps & { canEditAdvancedFeatures: boolean; updateApiKey: ( - apiKey: UpdateKeyInput, + projectValues: Partial, trackingData: UpdateAPIKeyTrackingData, ) => void; isUpdating: boolean; } > = (props) => { - const { canEditAdvancedFeatures, apiKey } = props; - const services: ApiKeyService[] = apiKey.services || []; + 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 + + + ); + } - const serviceIdx = services.findIndex( - (srv) => srv.name === "embeddedWallets", + return ( + ); - const config: ApiKeyService | undefined = services[serviceIdx]; +}; + +export const InAppWalletSettingsUI: React.FC< + Omit & { + canEditAdvancedFeatures: boolean; + updateApiKey: ( + projectValues: Partial, + trackingData: UpdateAPIKeyTrackingData, + ) => void; + isUpdating: boolean; + embeddedWalletService: ProjectEmbeddedWalletsService; + } +> = (props) => { + const { canEditAdvancedFeatures } = props; + const services = props.project.services; + + const config = props.embeddedWalletService; const hasCustomBranding = - !!config?.applicationImageUrl?.length || !!config?.applicationName?.length; + !!config.applicationImageUrl?.length || !!config.applicationName?.length; const form = useForm({ resolver: zodResolver(apiKeyEmbeddedWalletsValidationSchema), values: { - customAuthEndpoint: config?.customAuthEndpoint, - customAuthentication: config?.customAuthentication, + customAuthEndpoint: config.customAuthEndpoint || undefined, + customAuthentication: config.customAuthentication || undefined, ...(hasCustomBranding ? { branding: { - applicationName: config.applicationName, - applicationImageUrl: config.applicationImageUrl, + applicationName: config.applicationName || undefined, + applicationImageUrl: config.applicationImageUrl || undefined, }, } : undefined), - redirectUrls: apiKey.redirectUrls.join("\n"), + redirectUrls: (config.redirectUrls || []).join("\n"), }, }); @@ -168,29 +216,23 @@ export const InAppWalletSettingsUI: React.FC< ); } - const { id, name, domains, bundleIds } = apiKey; - - // FIXME: This must match components/settings/ApiKeys/Edit/index.tsx - // Make it more generic w/o me thinking of values - const newServices = [...services]; + const newServices = services.map((service) => { + if (service.name !== "embeddedWallets") { + return service; + } - if (services[serviceIdx]) { - newServices[serviceIdx] = { - ...services[serviceIdx], + return { + ...service, customAuthentication, customAuthEndpoint, applicationImageUrl: branding?.applicationImageUrl, - applicationName: branding?.applicationName || apiKey.name, + applicationName: branding?.applicationName || props.project.name, + redirectUrls: toArrFromList(redirectUrls || "", true), }; - } + }); props.updateApiKey( { - id, - name, - domains, - bundleIds, - redirectUrls: toArrFromList(redirectUrls || "", true), services: newServices, }, { diff --git a/apps/dashboard/src/components/pay/PayConfig.tsx b/apps/dashboard/src/components/pay/PayConfig.tsx index f1b70c65ed7..7696fdf287c 100644 --- a/apps/dashboard/src/components/pay/PayConfig.tsx +++ b/apps/dashboard/src/components/pay/PayConfig.tsx @@ -1,6 +1,9 @@ "use client"; +import type { Project } from "@/api/projects"; import { SettingsCard } from "@/components/blocks/SettingsCard"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Form, FormControl, @@ -9,34 +12,31 @@ import { FormLabel, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { type ApiKey, useUpdateApiKey } from "@3rdweb-sdk/react/hooks/useApi"; +import { updateProjectClient } from "@3rdweb-sdk/react/hooks/useApi"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; import { type ApiKeyPayConfigValidationSchema, apiKeyPayConfigValidationSchema, } from "components/settings/ApiKeys/validations"; import { useTrack } from "hooks/analytics/useTrack"; +import { CircleAlertIcon } from "lucide-react"; import Link from "next/link"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; interface PayConfigProps { - apiKey: Pick< - ApiKey, - | "services" - | "id" - | "name" - | "domains" - | "bundleIds" - | "services" - | "redirectUrls" - >; + project: Project; + teamId: string; + teamSlug: string; } const TRACKING_CATEGORY = "pay"; -export const PayConfig: React.FC = ({ apiKey }) => { - const payService = apiKey.services?.find((service) => service.name === "pay"); +export const PayConfig: React.FC = (props) => { + const payService = props.project.services.find( + (service) => service.name === "pay", + ); const form = useForm({ resolver: zodResolver(apiKeyPayConfigValidationSchema), @@ -47,13 +47,20 @@ export const PayConfig: React.FC = ({ apiKey }) => { const trackEvent = useTrack(); - const mutation = useUpdateApiKey(); + const updateProject = useMutation({ + mutationFn: async (projectValues: Partial) => { + await updateProjectClient( + { + projectId: props.project.id, + teamId: props.teamId, + }, + projectValues, + ); + }, + }); const handleSubmit = form.handleSubmit(({ payoutAddress }) => { - const services = apiKey.services; - if (!services) { - throw new Error("Bad state: Missing services"); - } + const services = props.project.services; const newServices = services.map((service) => { if (service.name !== "pay") { @@ -66,40 +73,54 @@ export const PayConfig: React.FC = ({ apiKey }) => { }; }); - const formattedValues = { - ...apiKey, - services: newServices, - }; - - const mutationPromise = mutation.mutateAsync(formattedValues, { - onSuccess: () => { - trackEvent({ - category: TRACKING_CATEGORY, - action: "configuration-update", - label: "success", - data: { - payoutAddress, - }, - }); + updateProject.mutate( + { + services: newServices, }, - onError: (err) => { - trackEvent({ - category: TRACKING_CATEGORY, - action: "configuration-update", - label: "error", - error: err, - }); + { + onSuccess: () => { + toast.success("Fee sharing updated"); + trackEvent({ + category: TRACKING_CATEGORY, + action: "configuration-update", + label: "success", + data: { + payoutAddress, + }, + }); + }, + onError: (err) => { + toast.error("Failed to update fee sharing"); + console.error(err); + trackEvent({ + category: TRACKING_CATEGORY, + action: "configuration-update", + label: "error", + error: err, + }); + }, }, - }); - - toast.promise(mutationPromise, { - success: "Changes saved", - error: (err) => { - return `Failed to save changes: ${err.message}`; - }, - }); + ); }); + if (!payService) { + return ( + + + Pay service is disabled + + Enable Pay service in{" "} + + project settings + {" "} + to configure settings + + + ); + } + return (
@@ -108,8 +129,8 @@ export const PayConfig: React.FC = ({ apiKey }) => { errorText={form.getFieldState("payoutAddress").error?.message} saveButton={{ type: "submit", - disabled: !apiKey.services || !form.formState.isDirty, - isPending: mutation.isPending, + disabled: !form.formState.isDirty, + isPending: updateProject.isPending, }} noPermissionText={undefined} > diff --git a/apps/dashboard/src/components/settings/ApiKeys/Create/CreateApiKeyModal.stories.tsx b/apps/dashboard/src/components/settings/ApiKeys/Create/CreateApiKeyModal.stories.tsx index c5055147494..f20b0af2152 100644 --- a/apps/dashboard/src/components/settings/ApiKeys/Create/CreateApiKeyModal.stories.tsx +++ b/apps/dashboard/src/components/settings/ApiKeys/Create/CreateApiKeyModal.stories.tsx @@ -1,11 +1,9 @@ import { Button } from "@/components/ui/button"; -import type { CreateKeyInput } from "@3rdweb-sdk/react/hooks/useApi"; import type { Meta, StoryObj } from "@storybook/react"; -import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import { Toaster } from "sonner"; -import { CreateAPIKeyDialogUI, type CreateAPIKeyPrefillOptions } from "."; -import { createApiKeyStub } from "../../../../stories/stubs"; +import { CreateProjectDialogUI, type CreateProjectPrefillOptions } from "."; +import { projectStub } from "../../../../stories/stubs"; import { mobileViewport } from "../../../../stories/utils"; const meta = { @@ -33,24 +31,22 @@ export const Mobile: Story = { }; function Story(props: { - prefill?: CreateAPIKeyPrefillOptions; + prefill?: CreateProjectPrefillOptions; }) { const [isOpen, setIsOpen] = useState(true); - const mutation = useMutation({ - mutationFn: async (input: CreateKeyInput) => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - const apiKey = createApiKeyStub(); - apiKey.name = input.name || apiKey.name; - return apiKey; - }, - }); return (
- { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { + project: projectStub("foo", "bar"), + secret: "123", + }; + }} prefill={props.prefill} enableNebulaServiceByDefault={false} teamSlug="foo" diff --git a/apps/dashboard/src/components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog.tsx b/apps/dashboard/src/components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog.tsx index 4d3d95059b7..def5aff28f6 100644 --- a/apps/dashboard/src/components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog.tsx +++ b/apps/dashboard/src/components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog.tsx @@ -1,11 +1,11 @@ "use client"; import { Suspense, lazy, useEffect, useState } from "react"; -import type { CreateAPIKeyDialogProps } from "./index"; +import type { CreateProjectDialogProps } from "./index"; -const CreateAPIKeyDialog = lazy(() => import("./index")); +const CreateProjectDialog = lazy(() => import("./index")); -export function LazyCreateAPIKeyDialog(props: CreateAPIKeyDialogProps) { +export function LazyCreateProjectDialog(props: CreateProjectDialogProps) { // if we use props.open to conditionally render the lazy component, - the dialog will close suddenly when the user closes it instead of gracefully fading out // and we can't render the dialog unconditionally because it will be rendered on the first page load and that defeats the purpose of lazy loading const [hasEverOpened, setHasEverOpened] = useState(false); @@ -20,7 +20,7 @@ export function LazyCreateAPIKeyDialog(props: CreateAPIKeyDialogProps) { if (hasEverOpened) { return ( - + ); } diff --git a/apps/dashboard/src/components/settings/ApiKeys/Create/index.tsx b/apps/dashboard/src/components/settings/ApiKeys/Create/index.tsx index f0737b70e3d..ec1dfb6bccc 100644 --- a/apps/dashboard/src/components/settings/ApiKeys/Create/index.tsx +++ b/apps/dashboard/src/components/settings/ApiKeys/Create/index.tsx @@ -1,4 +1,3 @@ -import { apiServerProxy } from "@/actions/proxies"; import type { Project } from "@/api/projects"; import { CopyTextButton } from "@/components/ui/CopyTextButton"; import { DynamicHeight } from "@/components/ui/DynamicHeight"; @@ -26,14 +25,11 @@ import { import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { useDashboardRouter } from "@/lib/DashboardRouter"; -import { - type ApiKey, - type CreateKeyInput, - useCreateApiKey, -} from "@3rdweb-sdk/react/hooks/useApi"; +import { createProjectClient } from "@3rdweb-sdk/react/hooks/useApi"; import { zodResolver } from "@hookform/resolvers/zod"; import { DialogDescription } from "@radix-ui/react-dialog"; -import { type UseMutationResult, useQuery } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; +import type { ProjectService } from "@thirdweb-dev/service-utils"; import { SERVICES } from "@thirdweb-dev/service-utils"; import { useTrack } from "hooks/analytics/useTrack"; import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react"; @@ -41,53 +37,61 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { toArrFromList } from "utils/string"; -import { - type ApiKeyCreateValidationSchema, - apiKeyCreateValidationSchema, -} from "../validations"; +import { z } from "zod"; +import { projectDomainsSchema, projectNameSchema } from "../validations"; + +const ALL_PROJECT_SERVICES = SERVICES.filter( + (srv) => srv.name !== "relayer" && srv.name !== "chainsaw", +); -export type CreateAPIKeyPrefillOptions = { +export type CreateProjectPrefillOptions = { name?: string; domains?: string; }; -export type CreateAPIKeyDialogProps = { +export type CreateProjectDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; onCreateAndComplete?: () => void; - prefill?: CreateAPIKeyPrefillOptions; + prefill?: CreateProjectPrefillOptions; enableNebulaServiceByDefault: boolean; - teamSlug: string | undefined; + teamId: string; + teamSlug: string; }; -const CreateAPIKeyDialog = (props: CreateAPIKeyDialogProps) => { - const createKeyMutation = useCreateApiKey(); - +const CreateProjectDialog = (props: CreateProjectDialogProps) => { return ( - + { + const res = await createProjectClient(props.teamId, params); + return { + project: res.project, + secret: res.secret, + }; + }} + {...props} + /> ); }; -export default CreateAPIKeyDialog; +export default CreateProjectDialog; -export const CreateAPIKeyDialogUI = (props: { +export const CreateProjectDialogUI = (props: { open: boolean; onOpenChange: (open: boolean) => void; onCreateAndComplete?: () => void; - createKeyMutation: UseMutationResult< - ApiKey, - unknown, - CreateKeyInput, - unknown - >; - prefill?: CreateAPIKeyPrefillOptions; + createProject: (param: Partial) => Promise<{ + project: Project; + secret: string; + }>; + prefill?: CreateProjectPrefillOptions; enableNebulaServiceByDefault: boolean; - teamSlug: string | undefined; + teamSlug: string; }) => { const [screen, setScreen] = useState< - { id: "create" } | { id: "api-details"; key: ApiKey } + { id: "create" } | { id: "api-details"; project: Project; secret: string } >({ id: "create" }); - const { open, onOpenChange, createKeyMutation } = props; + const { open, onOpenChange } = props; return ( {screen.id === "create" && ( - { - setScreen({ id: "api-details", key }); + { + setScreen({ + id: "api-details", + project: params.project, + secret: params.secret, + }); }} prefill={props.prefill} enableNebulaServiceByDefault={props.enableNebulaServiceByDefault} @@ -119,8 +127,9 @@ export const CreateAPIKeyDialogUI = (props: { )} {screen.id === "api-details" && ( - { onOpenChange(false); @@ -135,24 +144,33 @@ export const CreateAPIKeyDialogUI = (props: { ); }; -function CreateAPIKeyForm(props: { - createKeyMutation: UseMutationResult< - ApiKey, - unknown, - CreateKeyInput, - unknown - >; - onAPIKeyCreated: (key: ApiKey) => void; - prefill?: CreateAPIKeyPrefillOptions; +const createProjectFormSchema = z.object({ + name: projectNameSchema, + domains: projectDomainsSchema, +}); + +type CreateProjectFormSchema = z.infer; + +function CreateProjectForm(props: { + createProject: (param: Partial) => Promise<{ + project: Project; + secret: string; + }>; + prefill?: CreateProjectPrefillOptions; enableNebulaServiceByDefault: boolean; + onProjectCreated: (params: { + project: Project; + secret: string; + }) => void; }) { const [showAlert, setShowAlert] = useState<"no-domain" | "any-domain">(); - - const { createKeyMutation } = props; const trackEvent = useTrack(); + const createProject = useMutation({ + mutationFn: props.createProject, + }); - const form = useForm({ - resolver: zodResolver(apiKeyCreateValidationSchema), + const form = useForm({ + resolver: zodResolver(createProjectFormSchema), defaultValues: { name: props.prefill?.name || "", domains: props.prefill?.domains || "", @@ -164,22 +182,34 @@ function CreateAPIKeyForm(props: { domains: string; }) { const servicesToEnableByDefault = props.enableNebulaServiceByDefault - ? SERVICES - : SERVICES.filter((srv) => srv.name !== "nebula"); + ? ALL_PROJECT_SERVICES + : ALL_PROJECT_SERVICES.filter((srv) => srv.name !== "nebula"); - const formattedValues = { + const formattedValues: Partial = { name: values.name, domains: toArrFromList(values.domains), // enable all services - services: servicesToEnableByDefault.map((srv) => ({ - name: srv.name, - targetAddresses: ["*"], - enabled: true, - actions: srv.actions.map((sa) => sa.name), - recoveryShareManagement: "AWS_MANAGED", - customAuthentication: undefined, - applicationName: srv.name, - })), + services: servicesToEnableByDefault.map((srv) => { + if (srv.name === "storage") { + return { + name: srv.name, + actions: srv.actions.map((sa) => sa.name), + } satisfies ProjectService; + } + + if (srv.name === "pay") { + return { + name: "pay", + payoutAddress: null, + actions: [], + } satisfies ProjectService; + } + + return { + name: srv.name, + actions: [], + } satisfies ProjectService; + }), }; trackEvent({ @@ -188,10 +218,10 @@ function CreateAPIKeyForm(props: { label: "attempt", }); - createKeyMutation.mutate(formattedValues, { + createProject.mutate(formattedValues, { onSuccess: (data) => { + props.onProjectCreated(data); toast.success("Project created successfully"); - props.onAPIKeyCreated(data); trackEvent({ category: "api-keys", action: "create", @@ -227,7 +257,7 @@ function CreateAPIKeyForm(props: { return ( { handleAPICreation({ name: form.getValues("name"), @@ -343,12 +373,10 @@ function CreateAPIKeyForm(props: { @@ -405,44 +433,22 @@ function DomainsAlert(props: { ); } -function APIKeyDetails(props: { - apiKey: ApiKey; +function CreatedProjectDetails(props: { + project: Project; + secret: string; onComplete: () => void; teamSlug: string | undefined; }) { - const { apiKey } = props; const [secretStored, setSecretStored] = useState(false); const router = useDashboardRouter(); - // get the project.slug for the apiKey to render "View Project" button - const projectQuery = useQuery({ - queryKey: ["project", props.teamSlug, apiKey.id], - queryFn: async () => { - const res = await apiServerProxy<{ - result: Project[]; - }>({ - method: "GET", - pathname: `/v1/teams/${props.teamSlug}/projects`, - }); - - if (!res.ok) { - throw new Error(res.error); - } - - const projects = res.data.result; - const project = projects.find((p) => p.publishableKey === apiKey.key); - return project || null; - }, - enabled: !!props.teamSlug, - }); - - const projectSlug = projectQuery.data?.slug; + const clientId = props.project.publishableKey; return (
- {props.apiKey.name} + {props.project.name}
@@ -454,9 +460,9 @@ function APIKeyDetails(props: {

@@ -471,9 +477,9 @@ function APIKeyDetails(props: {

@@ -515,20 +521,13 @@ function APIKeyDetails(props: { {props.teamSlug && ( )} diff --git a/apps/dashboard/src/components/settings/ApiKeys/validations.ts b/apps/dashboard/src/components/settings/ApiKeys/validations.ts index 0db34dd5796..cc793417e31 100644 --- a/apps/dashboard/src/components/settings/ApiKeys/validations.ts +++ b/apps/dashboard/src/components/settings/ApiKeys/validations.ts @@ -1,14 +1,13 @@ -import { isAddress } from "thirdweb/utils"; -import { RE_BUNDLE_ID, RE_DOMAIN } from "utils/regex"; +import { RE_DOMAIN } from "utils/regex"; import { validStrList } from "utils/validations"; import { z } from "zod"; -const nameValidation = z +export const projectNameSchema = z .string() .min(3, { message: "Must be at least 3 chars" }) .max(64, { message: "Must be max 64 chars" }); -const domainsValidation = z.string().refine( +export const projectDomainsSchema = z.string().refine( (str) => validStrList(str, (domain) => { return domain.startsWith("localhost:") || RE_DOMAIN.test(domain); @@ -34,12 +33,6 @@ const customAuthEndpointValidation = z.union([ }), ]); -const recoverManagementValidation = z - // This should be the same as @thirdweb-dev/wallets RecoveryShareManagement enum - // Aso needs to be kept in sync with the type in `useApi.ts` - .enum(["AWS_MANAGED", "USER_MANAGED"]) - .optional(); - const applicationNameValidation = z.union([z.undefined(), z.string()]); const applicationImageUrlValidation = z.union([ @@ -67,34 +60,6 @@ const payoutAddressValidation = z .string() .regex(/(\b0x[a-fA-F0-9]{40}\b)/, "Please enter a valid address"); -const servicesValidation = z.optional( - z - .array( - z.object({ - name: z.string(), - enabled: z.boolean().optional(), - targetAddresses: z - .string() - .refine((str) => validStrList(str, isAddress), { - message: "Some of the addresses are invalid", - }), - actions: z.array(z.string()), - recoveryShareManagement: recoverManagementValidation, - customAuthentication: customAuthValidation, - customAuthEndpoint: customAuthEndpointValidation, - applicationName: applicationNameValidation, - applicationImageUrl: applicationImageUrlValidation, - }), - ) - .optional(), -); - -export const apiKeyCreateValidationSchema = z.object({ - name: nameValidation, - domains: domainsValidation, - services: servicesValidation, -}); - function isValidRedirectURI(uri: string) { // whitespace is not allowed if (/\s/g.test(uri)) { @@ -133,19 +98,6 @@ const redirectUriSchema = z message: "Wildcard redirect URIs are not allowed", }); -// TODO: move this schema to project settings folder in separate PR -export const projectSettingsPageFormSchema = z.object({ - name: nameValidation, - domains: domainsValidation, - services: servicesValidation, - bundleIds: z.string().refine((str) => validStrList(str, RE_BUNDLE_ID), { - message: "Some of the bundle ids are invalid", - }), - // no strict validation for redirectUrls, because project general page does not render redirectUrls form field - // so if the user has already saved an invalid `redirectUrls` on in-app wallet project settings page ( which is fixed now ) - it won't prevent them from updating the general project settings - redirectUrls: z.string(), -}); - export const apiKeyEmbeddedWalletsValidationSchema = z.object({ customAuthentication: customAuthValidation, customAuthEndpoint: customAuthEndpointValidation, @@ -163,14 +115,6 @@ export const apiKeyPayConfigValidationSchema = z.object({ payoutAddress: payoutAddressValidation, }); -export type ApiKeyCreateValidationSchema = z.infer< - typeof apiKeyCreateValidationSchema ->; - -export type ProjectSettingsPageFormSchema = z.infer< - typeof projectSettingsPageFormSchema ->; - export type ApiKeyEmbeddedWalletsValidationSchema = z.infer< typeof apiKeyEmbeddedWalletsValidationSchema >; diff --git a/apps/dashboard/src/components/smart-wallets/SponsorshipPolicies/index.tsx b/apps/dashboard/src/components/smart-wallets/SponsorshipPolicies/index.tsx index 960f78122e1..77c1a168bdf 100644 --- a/apps/dashboard/src/components/smart-wallets/SponsorshipPolicies/index.tsx +++ b/apps/dashboard/src/components/smart-wallets/SponsorshipPolicies/index.tsx @@ -1,14 +1,7 @@ "use client"; import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors"; -import { - type Account, - type ApiKeyService, - type ApiKeyServicePolicy, - type ApiKeyServicePolicyLimits, - usePolicies, - useUpdatePolicies, -} from "@3rdweb-sdk/react/hooks/useApi"; +import { updateProjectClient } from "@3rdweb-sdk/react/hooks/useApi"; import { Box, Divider, @@ -21,6 +14,8 @@ import { Textarea, } from "@chakra-ui/react"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import type { ProjectBundlerService } from "@thirdweb-dev/service-utils"; import { GatedSwitch } from "components/settings/Account/Billing/GatedSwitch"; import { useTrack } from "hooks/analytics/useTrack"; import { useTxNotifications } from "hooks/useTxNotifications"; @@ -38,12 +33,15 @@ import { import { joinWithComma, toArrFromList } from "utils/string"; import { validStrList } from "utils/validations"; import { z } from "zod"; -import { GenericLoadingPage } from "../../../@/components/blocks/skeletons/GenericLoadingPage"; +import type { Project } from "../../../@/api/projects"; +import type { Team } from "../../../@/api/team"; type AccountAbstractionSettingsPageProps = { - apiKeyServices: ApiKeyService[]; + bundlerService: ProjectBundlerService; + project: Project; trackingCategory: string; - twAccount: Account; + teamId: string; + validTeamPlan: Team["billingPlan"]; }; const aaSettingsFormSchema = z.object({ @@ -98,48 +96,61 @@ const aaSettingsFormSchema = z.object({ export function AccountAbstractionSettingsPage( props: AccountAbstractionSettingsPageProps, ) { - const { apiKeyServices, trackingCategory } = props; - const bundlerServiceId = apiKeyServices?.find( - (s) => s.name === "bundler", - )?.id; - const policiesQuery = usePolicies(bundlerServiceId); - const { mutate: updatePolicy, isPending: updatingPolicy } = - useUpdatePolicies(); + const { trackingCategory } = props; const trackEvent = useTrack(); + const updateProject = useMutation({ + mutationFn: async (projectValues: Partial) => { + await updateProjectClient( + { + projectId: props.project.id, + teamId: props.teamId, + }, + projectValues, + ); + }, + }); - const policy = policiesQuery.data; + const policy = props.bundlerService; const transformedQueryData = useMemo( () => ({ allowedChainIds: - policy?.allowedChainIds && policy?.allowedChainIds?.length > 0 - ? policy?.allowedChainIds + policy.allowedChainIds && policy.allowedChainIds?.length > 0 + ? policy.allowedChainIds : null, allowedContractAddresses: - policy?.allowedContractAddresses && - policy?.allowedContractAddresses?.length > 0 - ? joinWithComma(policy?.allowedContractAddresses) + policy.allowedContractAddresses && + policy.allowedContractAddresses?.length > 0 + ? joinWithComma(policy.allowedContractAddresses) : null, allowedWallets: - policy?.allowedWallets && policy?.allowedWallets?.length > 0 - ? joinWithComma(policy?.allowedWallets) + policy.allowedWallets && policy.allowedWallets?.length > 0 + ? joinWithComma(policy.allowedWallets) : null, blockedWallets: - policy?.blockedWallets && policy?.blockedWallets?.length > 0 - ? joinWithComma(policy?.blockedWallets) + policy.blockedWallets && policy.blockedWallets?.length > 0 + ? joinWithComma(policy.blockedWallets) : null, bypassWallets: - policy?.bypassWallets && policy?.bypassWallets?.length > 0 - ? joinWithComma(policy?.bypassWallets) + policy.bypassWallets && policy.bypassWallets?.length > 0 + ? joinWithComma(policy.bypassWallets) : null, - serverVerifier: policy?.serverVerifier?.url - ? { ...policy.serverVerifier, enabled: true } - : { enabled: false, url: null, headers: null }, - globalLimit: policy?.limits?.global ?? null, + serverVerifier: policy.serverVerifier?.url + ? { + url: policy.serverVerifier.url, + headers: policy.serverVerifier.headers || null, + enabled: true, + } + : { + url: null, + headers: null, + enabled: false, + }, + globalLimit: policy.limits?.global ?? null, allowedOrBlockedWallets: - policy?.allowedWallets && policy?.allowedWallets?.length > 0 + policy.allowedWallets && policy.allowedWallets?.length > 0 ? "allowed" - : policy?.blockedWallets && policy?.blockedWallets?.length > 0 + : policy.blockedWallets && policy.blockedWallets?.length > 0 ? "blocked" : null, }), @@ -162,10 +173,6 @@ export function AccountAbstractionSettingsPage( "Failed to update sponsorship rules", ); - if (policiesQuery.isPending) { - return ; - } - return ( { - if (!bundlerServiceId) { - onError("No account abstraction service found for this API key"); - return; - } - const limits: ApiKeyServicePolicyLimits | null = values.globalLimit - ? { - global: { - maxSpend: values.globalLimit.maxSpend, - maxSpendUnit: values.globalLimit.maxSpendUnit, - }, - } - : null; - const parsedValues: ApiKeyServicePolicy = { - allowedContractAddresses: - values.allowedContractAddresses !== null - ? toArrFromList(values.allowedContractAddresses) - : null, - allowedChainIds: values.allowedChainIds, - allowedWallets: - values.allowedOrBlockedWallets === "allowed" && - values.allowedWallets !== null - ? toArrFromList(values.allowedWallets) - : null, - blockedWallets: - values.allowedOrBlockedWallets === "blocked" && - values.blockedWallets !== null - ? toArrFromList(values.blockedWallets) - : null, - bypassWallets: - values.bypassWallets !== null - ? toArrFromList(values.bypassWallets) - : null, - serverVerifier: - values.serverVerifier && - typeof values.serverVerifier.url === "string" && - values.serverVerifier.enabled - ? { - headers: values.serverVerifier.headers ?? [], - url: values.serverVerifier.url, - } - : null, - limits, - }; + const limits: ProjectBundlerService["limits"] | null = + values.globalLimit + ? { + global: { + maxSpend: values.globalLimit.maxSpend, + maxSpendUnit: values.globalLimit.maxSpendUnit, + }, + } + : null; + + const parsedValues: Omit = + { + allowedContractAddresses: + values.allowedContractAddresses !== null + ? toArrFromList(values.allowedContractAddresses) + : null, + allowedChainIds: values.allowedChainIds, + allowedWallets: + values.allowedOrBlockedWallets === "allowed" && + values.allowedWallets !== null + ? toArrFromList(values.allowedWallets) + : null, + blockedWallets: + values.allowedOrBlockedWallets === "blocked" && + values.blockedWallets !== null + ? toArrFromList(values.blockedWallets) + : null, + bypassWallets: + values.bypassWallets !== null + ? toArrFromList(values.bypassWallets) + : null, + serverVerifier: + values.serverVerifier && + typeof values.serverVerifier.url === "string" && + values.serverVerifier.enabled + ? { + headers: values.serverVerifier.headers ?? [], + url: values.serverVerifier.url, + } + : null, + limits, + }; trackEvent({ category: trackingCategory, action: "update-sponsorship-rules", label: "attempt", }); - updatePolicy( + + const newServices = props.project.services.map((service) => { + if (service.name === "bundler") { + const bundlerService: ProjectBundlerService = { + ...service, + actions: [], + ...parsedValues, + }; + + return bundlerService; + } + + return service; + }); + + updateProject.mutate( { - serviceId: bundlerServiceId, - data: parsedValues, + services: newServices, }, { onSuccess: () => { @@ -537,10 +557,10 @@ export function AccountAbstractionSettingsPage(
{ form.setValue( @@ -676,7 +696,7 @@ export function AccountAbstractionSettingsPage( diff --git a/apps/dashboard/src/stories/stubs.ts b/apps/dashboard/src/stories/stubs.ts index 893f1cadd5a..84eabb55ac1 100644 --- a/apps/dashboard/src/stories/stubs.ts +++ b/apps/dashboard/src/stories/stubs.ts @@ -1,32 +1,29 @@ import type { Project } from "@/api/projects"; import type { Team } from "@/api/team"; import type { TeamSubscription } from "@/api/team-subscription"; -import type { - Account, - ApiKey, - ApiKeyService, -} from "@3rdweb-sdk/react/hooks/useApi"; +import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; import type { EngineAlert, EngineAlertRule, EngineNotificationChannel, } from "@3rdweb-sdk/react/hooks/useEngine"; -function projectStub(id: string, teamId: string) { +export function projectStub(id: string, teamId: string) { const project: Project = { bundleIds: [] as string[], - createdAt: new Date(), + createdAt: new Date().toISOString(), domains: [] as string[], id: id, - updatedAt: new Date(), + updatedAt: new Date().toISOString(), teamId: teamId, - redirectUrls: [] as string[], slug: `project-${id}`, name: `Project ${id}`, publishableKey: "pb-key", lastAccessedAt: null, - deletedAt: null, - bannedAt: null, + image: null, + services: [], + walletAddresses: [], + secretKeys: [], }; return project; @@ -44,6 +41,9 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team { updatedAt: new Date().toISOString(), billingEmail: "foo@example.com", growthTrialEligible: false, + billingPlanVersion: 1, + canCreatePublicChains: null, + image: null, enabledScopes: [ "pay", "storage", @@ -80,51 +80,6 @@ export const teamsAndProjectsStub: Array<{ team: Team; projects: Project[] }> = }, ]; -function generateRandomString(length: number) { - const characters = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let result = ""; - - for (let i = 0; i < length; i++) { - const randomIndex = Math.floor(Math.random() * characters.length); - result += characters.charAt(randomIndex); - } - - return result; -} - -export function createApiKeyStub() { - const embeddedWalletService: ApiKeyService = { - id: "embeddedWallets", - name: "embeddedWallets", // important - targetAddresses: [], - actions: [], - }; - - const secretKey = generateRandomString(86); - - const apiKeyStub: ApiKey = { - id: "api-key-id-foo", - name: "xyz", - key: generateRandomString(31), - accountId: "account-id-foo", - bundleIds: ["bundle-id-foo", "bundle-id-bar"], - createdAt: new Date().toISOString(), - creatorWalletAddress: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37", - domains: ["example1.com", "example2.com"], - secretMasked: `${secretKey.slice(0, 3)}...${secretKey.slice(-4)}`, - walletAddresses: ["0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37"], - redirectUrls: [], - revokedAt: "", - lastAccessedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - services: [embeddedWalletService], - secret: secretKey, - }; - - return apiKeyStub; -} - export function createEngineAlertRuleStub( id: string, overrides: Partial = {}, diff --git a/packages/service-utils/src/core/api.ts b/packages/service-utils/src/core/api.ts index 4d4f8309f93..2b8e7c6e224 100644 --- a/packages/service-utils/src/core/api.ts +++ b/packages/service-utils/src/core/api.ts @@ -45,9 +45,10 @@ export type TeamResponse = { slug: string; image: string | null; billingPlan: "free" | "starter" | "growth" | "pro"; + supportPlan: "free" | "starter" | "growth" | "pro"; billingPlanVersion: number; - createdAt: Date; - updatedAt: Date | null; + createdAt: string; + updatedAt: string | null; billingEmail: string | null; billingStatus: "noPayment" | "validPayment" | "invalidPayment" | null; growthTrialEligible: false; @@ -55,73 +56,88 @@ export type TeamResponse = { enabledScopes: ServiceName[]; }; +export type ProjectSecretKey = { + hash: string; + masked: string; + createdAt: string; + updatedAt: string; +}; + +export type ProjectBundlerService = { + name: "bundler"; + actions: never[]; + allowedChainIds?: number[] | null; + allowedContractAddresses?: string[] | null; + allowedWallets?: string[] | null; + blockedWallets?: string[] | null; + bypassWallets?: string[] | null; + limits?: { + global?: { + maxSpend: string; + maxSpendUnit: "usd" | "native"; + } | null; + } | null; + serverVerifier?: { + url: string; + headers?: Array<{ + key: string; + value: string; + }>; + } | null; +}; + +export type ProjectEmbeddedWalletsService = { + name: "embeddedWallets"; + actions: never[]; + redirectUrls?: string[] | null; + applicationName?: string | null; + applicationImageUrl?: string | null; + recoveryShareManagement?: string | null; + customAuthentication?: CustomAuthenticationServiceSchema | null; + customAuthEndpoint?: CustomAuthEndpointServiceSchema | null; +}; + +export type ProjectService = + | { + name: "pay"; + actions: never[]; + payoutAddress: string | null; + developerFeeBPS?: number | null; + } + | { + name: "storage"; + actions: ("read" | "write")[]; + } + | { + name: "rpc"; + actions: never[]; + } + | { + name: "insight"; + actions: never[]; + } + | { + name: "nebula"; + actions: never[]; + } + | ProjectBundlerService + | ProjectEmbeddedWalletsService; + export type ProjectResponse = { id: string; teamId: string; - createdAt: Date; - updatedAt: Date | null; + createdAt: string; + updatedAt: string | null; + lastAccessedAt: string | null; publishableKey: string; name: string; slug: string; image: string | null; domains: string[]; bundleIds: string[]; - services: ( - | { - name: "pay"; - actions: never[]; - payoutAddress: string | null; - } - | { - name: "storage"; - actions: ("read" | "write")[]; - } - | { - name: "rpc"; - actions: never[]; - } - | { - name: "insight"; - actions: never[]; - } - | { - name: "nebula"; - actions: never[]; - } - | { - name: "bundler"; - actions: never[]; - allowedChainIds?: number[] | null; - allowedContractAddresses?: string[] | null; - allowedWallets?: string[] | null; - blockedWallets?: string[] | null; - bypassWallets?: string[] | null; - limits?: { - global?: { - maxSpend: string; - maxSpendUnit: "usd" | "native"; - } | null; - } | null; - serverVerifier?: { - url: string; - headers?: { - key: string; - value: string; - }[]; - } | null; - } - | { - name: "embeddedWallets"; - actions: never[]; - redirectUrls?: string[] | null; - applicationName?: string | null; - applicationImageUrl?: string | null; - recoveryShareManagement?: string | null; - customAuthentication?: CustomAuthenticationServiceSchema | null; - customAuthEndpoint?: CustomAuthEndpointServiceSchema | null; - } - )[]; + services: ProjectService[]; walletAddresses: string[]; + secretKeys: ProjectSecretKey[]; }; type CustomAuthenticationServiceSchema = { diff --git a/packages/service-utils/src/index.ts b/packages/service-utils/src/index.ts index 2bb94013d76..7c55de31136 100644 --- a/packages/service-utils/src/index.ts +++ b/packages/service-utils/src/index.ts @@ -6,8 +6,12 @@ export type { PolicyResult, UserOpData, ProjectResponse, + ProjectSecretKey, + ProjectBundlerService, + ProjectService, TeamAndProjectResponse, TeamResponse, + ProjectEmbeddedWalletsService, } from "./core/api.js"; export { fetchTeamAndProject } from "./core/api.js"; diff --git a/packages/service-utils/src/mocks.ts b/packages/service-utils/src/mocks.ts index 6e61ee7f34a..c29f3373cee 100644 --- a/packages/service-utils/src/mocks.ts +++ b/packages/service-utils/src/mocks.ts @@ -27,11 +27,20 @@ export const validProjectResponse: ProjectResponse = { }, ], teamId: "1", - createdAt: new Date("2024-06-01"), - updatedAt: new Date("2024-06-01"), + createdAt: new Date("2024-06-01").toISOString(), + updatedAt: new Date("2024-06-01").toISOString(), + lastAccessedAt: new Date("2024-06-01").toISOString(), name: "test-project", slug: "test-project", image: "https://example.com/image.png", + secretKeys: [ + { + hash: "1234567890123456789012345678901234567890123456789012345678901234", + masked: "foo...lorem", + createdAt: new Date("2024-06-01").toISOString(), + updatedAt: new Date("2024-06-01").toISOString(), + }, + ], }; export const validTeamResponse: TeamResponse = { @@ -39,9 +48,10 @@ export const validTeamResponse: TeamResponse = { name: "test-team", slug: "test-team", image: "https://example.com/image.png", - createdAt: new Date("2024-06-01"), - updatedAt: new Date("2024-06-01"), + createdAt: new Date("2024-06-01").toISOString(), + updatedAt: new Date("2024-06-01").toISOString(), billingPlan: "free", + supportPlan: "free", billingPlanVersion: 1, billingEmail: "test@example.com", billingStatus: "noPayment",