From 759758feebf23ee3bf923316d0cc0a33eda0e19a Mon Sep 17 00:00:00 2001 From: Jonas Daniels Date: Fri, 25 Apr 2025 00:30:37 +0200 Subject: [PATCH] Add domain verification functionality for teams --- apps/dashboard/src/@/api/verified-domain.ts | 94 +++++++ .../settings-cards/domain-verification.tsx | 229 ++++++++++++++++++ .../general/GeneralSettingsPage.stories.tsx | 2 + .../general/TeamGeneralSettingsPage.tsx | 5 + .../general/TeamGeneralSettingsPageUI.tsx | 10 + .../[team_slug]/(team)/~/settings/page.tsx | 14 +- .../components/TeamHeader/TeamHeaderUI.tsx | 7 +- .../components/TeamHeader/TeamSelectionUI.tsx | 14 +- .../TeamHeader/team-verified-icon.tsx | 22 ++ apps/dashboard/src/stories/stubs.ts | 1 + packages/service-utils/src/core/api.ts | 1 + packages/service-utils/src/mocks.ts | 1 + 12 files changed, 391 insertions(+), 9 deletions(-) create mode 100644 apps/dashboard/src/@/api/verified-domain.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/_components/settings-cards/domain-verification.tsx create mode 100644 apps/dashboard/src/app/(app)/team/components/TeamHeader/team-verified-icon.tsx diff --git a/apps/dashboard/src/@/api/verified-domain.ts b/apps/dashboard/src/@/api/verified-domain.ts new file mode 100644 index 00000000000..e0c9ce80496 --- /dev/null +++ b/apps/dashboard/src/@/api/verified-domain.ts @@ -0,0 +1,94 @@ +"use server"; +import "server-only"; + +import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken"; +import { API_SERVER_URL } from "../constants/env"; + +export type VerifiedDomainResponse = + | { + status: "pending"; + domain: string; + dnsSublabel: string; + dnsValue: string; + } + | { + status: "verified"; + domain: string; + verifiedAt: Date; + }; + +export async function checkDomainVerification( + teamIdOrSlug: string, +): Promise { + const token = await getAuthToken(); + + if (!token) { + return null; + } + + const res = await fetch( + `${API_SERVER_URL}/v1/teams/${teamIdOrSlug}/verified-domain`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + if (res.ok) { + return (await res.json())?.result as VerifiedDomainResponse; + } + + return null; +} + +export async function createDomainVerification( + teamIdOrSlug: string, + domain: string, +): Promise { + const token = await getAuthToken(); + + if (!token) { + return { + error: "Unauthorized", + }; + } + + const res = await fetch( + `${API_SERVER_URL}/v1/teams/${teamIdOrSlug}/verified-domain`, + { + method: "POST", + body: JSON.stringify({ domain }), + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (res.ok) { + return (await res.json())?.result as VerifiedDomainResponse; + } + + const resJson = (await res.json()) as { + error: { + code: string; + message: string; + statusCode: number; + }; + }; + + switch (resJson?.error?.statusCode) { + case 400: + return { + error: "The domain you provided is not valid.", + }; + case 409: + return { + error: "This domain is already verified by another team.", + }; + default: + return { + error: resJson?.error?.message ?? "Failed to verify domain", + }; + } +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/_components/settings-cards/domain-verification.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/_components/settings-cards/domain-verification.tsx new file mode 100644 index 00000000000..eb45ac32b14 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/_components/settings-cards/domain-verification.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { + type VerifiedDomainResponse, + checkDomainVerification, + createDomainVerification, +} from "@/api/verified-domain"; +import { SettingsCard } from "@/components/blocks/SettingsCard"; +import { CopyButton } from "@/components/ui/CopyButton"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertCircle, CheckCircle } from "lucide-react"; +import { useState } from "react"; + +interface DomainVerificationFormProps { + teamId: string; + initialVerification: VerifiedDomainResponse | null; + isOwnerAccount: boolean; +} + +export function TeamDomainVerificationCard({ + initialVerification, + isOwnerAccount, + teamId, +}: DomainVerificationFormProps) { + const [domain, setDomain] = useState(""); + const queryClient = useQueryClient(); + + const domainQuery = useQuery({ + queryKey: ["domain-verification", teamId], + queryFn: () => checkDomainVerification(teamId), + initialData: initialVerification, + refetchInterval: (query) => { + // if the data is pending, refetch every 10 seconds + if (query.state.data?.status === "pending") { + return 10000; + } + // if the data is verified, don't refetch ever + return false; + }, + }); + + const verificationMutation = useMutation({ + mutationFn: async (params: { teamId: string; domain: string }) => { + const res = await createDomainVerification(params.teamId, params.domain); + if ("error" in res) { + throw new Error(res.error); + } + return res; + }, + onSuccess: (data) => { + queryClient.setQueryData(["domain-verification", teamId], data); + }, + }); + + // Get the appropriate bottom text based on verification status + const getBottomText = () => { + if (!domainQuery.data) { + return "Domains must be verified before use."; + } + + if (domainQuery.data.status === "pending") { + return "Your domain verification is pending. Please add the DNS record to complete verification."; + } + + return `Domain ${domainQuery.data.domain} has been successfully verified.`; + }; + + // Render the content for the settings card + const renderContent = () => { + // Initial state - show domain input form + if (!domainQuery.data) { + return ( +
+ setDomain(e.target.value)} + disabled={!isOwnerAccount || verificationMutation.isPending} + // is the max length for a domain + maxLength={253} + className="md:w-[450px]" + /> +

+ Enter the domain you want to verify. Do not include http(s):// or + www. +

+
+ ); + } + + // Pending verification state + if (domainQuery.data.status === "pending") { + return ( +
+
+
+

Domain

+ + Pending + +
+

{domainQuery.data.domain}

+
+ +
+

+ Before we can verify {domainQuery.data.domain}, you need to + add the following DNS TXT record: +

+ +
+
+

+ Name / Host / Alias +

+ +
+ + {domainQuery.data.dnsSublabel}{" "} + + + .{domainQuery.data.domain} +
+
+ +
+

+ Value / Content +

+
+ + {domainQuery.data.dnsValue}{" "} + + +
+
+
+ + + + + DNS changes can take up to 48 hours to propagate. + + + We'll automatically check the status periodically. You can + manually check the status by clicking the button below. + + + + +
+
+ ); + } + + // Verified state + return ( +
+
+
+ +
+

{domainQuery.data.domain}

+

+ Verified on{" "} + {new Date(domainQuery.data.verifiedAt).toLocaleDateString()} +

+
+
+ + Verified + +
+
+ ); + }; + + return ( + + verificationMutation.mutate({ + teamId, + domain, + }), + disabled: !domain || verificationMutation.isPending, + isPending: verificationMutation.isPending, + label: "Verify Domain", + } + : undefined + } + > + {renderContent()} + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx index 86fb88f471f..1664e6bd243 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx @@ -42,6 +42,8 @@ function Story() { leaveTeam={async () => { await new Promise((resolve) => setTimeout(resolve, 1000)); }} + initialVerification={null} + isOwnerAccount={true} /> diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPage.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPage.tsx index 14486977516..17c8fcb825b 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPage.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPage.tsx @@ -2,6 +2,7 @@ import { apiServerProxy } from "@/actions/proxies"; import type { Team } from "@/api/team"; +import type { VerifiedDomainResponse } from "@/api/verified-domain"; import { useDashboardRouter } from "@/lib/DashboardRouter"; import type { ThirdwebClient } from "thirdweb"; import { upload } from "thirdweb/storage"; @@ -10,6 +11,8 @@ import { updateTeam } from "./updateTeam"; export function TeamGeneralSettingsPage(props: { team: Team; + initialVerification: VerifiedDomainResponse | null; + isOwnerAccount: boolean; client: ThirdwebClient; accountId: string; }) { @@ -72,6 +75,8 @@ export function TeamGeneralSettingsPage(props: { router.refresh(); }} + initialVerification={props.initialVerification} + isOwnerAccount={props.isOwnerAccount} /> ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx index 6c198843434..bc0b704cde7 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx @@ -1,6 +1,7 @@ "use client"; import type { Team } from "@/api/team"; +import type { VerifiedDomainResponse } from "@/api/verified-domain"; import { DangerSettingCard } from "@/components/blocks/DangerSettingCard"; import { SettingsCard } from "@/components/blocks/SettingsCard"; import { CopyTextButton } from "@/components/ui/CopyTextButton"; @@ -12,12 +13,15 @@ import { FileInput } from "components/shared/FileInput"; import { useState } from "react"; import { toast } from "sonner"; import type { ThirdwebClient } from "thirdweb"; +import { TeamDomainVerificationCard } from "../_components/settings-cards/domain-verification"; import { teamSlugRegex } from "./common"; type UpdateTeamField = (team: Partial) => Promise; export function TeamGeneralSettingsPageUI(props: { team: Team; + initialVerification: VerifiedDomainResponse | null; + isOwnerAccount: boolean; updateTeamImage: (file: File | undefined) => Promise; updateTeamField: UpdateTeamField; client: ThirdwebClient; @@ -40,6 +44,12 @@ export function TeamGeneralSettingsPageUI(props: { client={props.client} /> + + ); } diff --git a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx index b38b9bc8aba..3ca847e210d 100644 --- a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx +++ b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx @@ -18,6 +18,7 @@ import { ProjectSelectorMobileMenuButton } from "./ProjectSelectorMobileMenuButt import { TeamAndProjectSelectorPopoverButton } from "./TeamAndProjectSelectorPopoverButton"; import { TeamSelectorMobileMenuButton } from "./TeamSelectorMobileMenuButton"; import { getValidTeamPlan } from "./getValidTeamPlan"; +import { TeamVerifiedIcon } from "./team-verified-icon"; export type TeamHeaderCompProps = { currentTeam: Team; @@ -65,6 +66,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) { client={props.client} /> {currentTeam.name} + @@ -156,7 +158,10 @@ export function TeamHeaderMobileUI(props: TeamHeaderCompProps) { /> {!props.currentProject && ( - {currentTeam.name} +
+ {currentTeam.name} + +
)} diff --git a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamSelectionUI.tsx b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamSelectionUI.tsx index 59b862da670..12274587b10 100644 --- a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamSelectionUI.tsx +++ b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamSelectionUI.tsx @@ -7,12 +7,14 @@ import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; -import { CheckIcon, CirclePlusIcon } from "lucide-react"; +import { CirclePlusIcon } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; import type { ThirdwebClient } from "thirdweb"; +import { TeamPlanBadge } from "../../../components/TeamPlanBadge"; import { SearchInput } from "./SearchInput"; import { getValidTeamPlan } from "./getValidTeamPlan"; +import { TeamVerifiedIcon } from "./team-verified-icon"; export function TeamSelectionUI(props: { setHoveredTeam: (team: Team | undefined) => void; @@ -68,11 +70,11 @@ export function TeamSelectionUI(props: {
    {filteredTeams.map((team) => { - const isSelected = team.slug === currentTeam?.slug; + const isSelected = team.id === currentTeam?.id; return ( // biome-ignore lint/a11y/useKeyWithMouseEvents:
  • { setHoveredTeam(team); @@ -96,10 +98,10 @@ export function TeamSelectionUI(props: { /> {team.name} + - {isSelected && ( - - )} + +
  • diff --git a/apps/dashboard/src/app/(app)/team/components/TeamHeader/team-verified-icon.tsx b/apps/dashboard/src/app/(app)/team/components/TeamHeader/team-verified-icon.tsx new file mode 100644 index 00000000000..fb88b3faf92 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/components/TeamHeader/team-verified-icon.tsx @@ -0,0 +1,22 @@ +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { VerifiedIcon } from "lucide-react"; + +export function TeamVerifiedIcon(props: { + domain: string | null; +}) { + if (!props.domain) { + return null; + } + + return ( + + {props.domain} is verified + + } + > + + + ); +} diff --git a/apps/dashboard/src/stories/stubs.ts b/apps/dashboard/src/stories/stubs.ts index 3b688762e21..fc801af8e5f 100644 --- a/apps/dashboard/src/stories/stubs.ts +++ b/apps/dashboard/src/stories/stubs.ts @@ -98,6 +98,7 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team { }, planCancellationDate: null, unthreadCustomerId: null, + verifiedDomain: null, }; return team; diff --git a/packages/service-utils/src/core/api.ts b/packages/service-utils/src/core/api.ts index 38fa4f30ad9..7bd0c9f5766 100644 --- a/packages/service-utils/src/core/api.ts +++ b/packages/service-utils/src/core/api.ts @@ -126,6 +126,7 @@ export type TeamResponse = { capabilities: TeamCapabilities; unthreadCustomerId: string | null; planCancellationDate: string | null; + verifiedDomain: string | null; }; export type ProjectSecretKey = { diff --git a/packages/service-utils/src/mocks.ts b/packages/service-utils/src/mocks.ts index 53c673dc04b..377a6f630c5 100644 --- a/packages/service-utils/src/mocks.ts +++ b/packages/service-utils/src/mocks.ts @@ -59,6 +59,7 @@ export const validTeamResponse: TeamResponse = { canCreatePublicChains: false, enabledScopes: ["storage", "rpc", "bundler"], isOnboarded: true, + verifiedDomain: null, capabilities: { rpc: { enabled: true,