Skip to content

Commit 759758f

Browse files
committed
Add domain verification functionality for teams
1 parent 31e87b0 commit 759758f

File tree

12 files changed

+391
-9
lines changed

12 files changed

+391
-9
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"use server";
2+
import "server-only";
3+
4+
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
5+
import { API_SERVER_URL } from "../constants/env";
6+
7+
export type VerifiedDomainResponse =
8+
| {
9+
status: "pending";
10+
domain: string;
11+
dnsSublabel: string;
12+
dnsValue: string;
13+
}
14+
| {
15+
status: "verified";
16+
domain: string;
17+
verifiedAt: Date;
18+
};
19+
20+
export async function checkDomainVerification(
21+
teamIdOrSlug: string,
22+
): Promise<VerifiedDomainResponse | null> {
23+
const token = await getAuthToken();
24+
25+
if (!token) {
26+
return null;
27+
}
28+
29+
const res = await fetch(
30+
`${API_SERVER_URL}/v1/teams/${teamIdOrSlug}/verified-domain`,
31+
{
32+
headers: {
33+
Authorization: `Bearer ${token}`,
34+
},
35+
},
36+
);
37+
if (res.ok) {
38+
return (await res.json())?.result as VerifiedDomainResponse;
39+
}
40+
41+
return null;
42+
}
43+
44+
export async function createDomainVerification(
45+
teamIdOrSlug: string,
46+
domain: string,
47+
): Promise<VerifiedDomainResponse | { error: string }> {
48+
const token = await getAuthToken();
49+
50+
if (!token) {
51+
return {
52+
error: "Unauthorized",
53+
};
54+
}
55+
56+
const res = await fetch(
57+
`${API_SERVER_URL}/v1/teams/${teamIdOrSlug}/verified-domain`,
58+
{
59+
method: "POST",
60+
body: JSON.stringify({ domain }),
61+
headers: {
62+
"Content-Type": "application/json",
63+
Authorization: `Bearer ${token}`,
64+
},
65+
},
66+
);
67+
68+
if (res.ok) {
69+
return (await res.json())?.result as VerifiedDomainResponse;
70+
}
71+
72+
const resJson = (await res.json()) as {
73+
error: {
74+
code: string;
75+
message: string;
76+
statusCode: number;
77+
};
78+
};
79+
80+
switch (resJson?.error?.statusCode) {
81+
case 400:
82+
return {
83+
error: "The domain you provided is not valid.",
84+
};
85+
case 409:
86+
return {
87+
error: "This domain is already verified by another team.",
88+
};
89+
default:
90+
return {
91+
error: resJson?.error?.message ?? "Failed to verify domain",
92+
};
93+
}
94+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"use client";
2+
3+
import {
4+
type VerifiedDomainResponse,
5+
checkDomainVerification,
6+
createDomainVerification,
7+
} from "@/api/verified-domain";
8+
import { SettingsCard } from "@/components/blocks/SettingsCard";
9+
import { CopyButton } from "@/components/ui/CopyButton";
10+
import { Spinner } from "@/components/ui/Spinner/Spinner";
11+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
12+
import { Button } from "@/components/ui/button";
13+
import { Input } from "@/components/ui/input";
14+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
15+
import { AlertCircle, CheckCircle } from "lucide-react";
16+
import { useState } from "react";
17+
18+
interface DomainVerificationFormProps {
19+
teamId: string;
20+
initialVerification: VerifiedDomainResponse | null;
21+
isOwnerAccount: boolean;
22+
}
23+
24+
export function TeamDomainVerificationCard({
25+
initialVerification,
26+
isOwnerAccount,
27+
teamId,
28+
}: DomainVerificationFormProps) {
29+
const [domain, setDomain] = useState("");
30+
const queryClient = useQueryClient();
31+
32+
const domainQuery = useQuery({
33+
queryKey: ["domain-verification", teamId],
34+
queryFn: () => checkDomainVerification(teamId),
35+
initialData: initialVerification,
36+
refetchInterval: (query) => {
37+
// if the data is pending, refetch every 10 seconds
38+
if (query.state.data?.status === "pending") {
39+
return 10000;
40+
}
41+
// if the data is verified, don't refetch ever
42+
return false;
43+
},
44+
});
45+
46+
const verificationMutation = useMutation({
47+
mutationFn: async (params: { teamId: string; domain: string }) => {
48+
const res = await createDomainVerification(params.teamId, params.domain);
49+
if ("error" in res) {
50+
throw new Error(res.error);
51+
}
52+
return res;
53+
},
54+
onSuccess: (data) => {
55+
queryClient.setQueryData(["domain-verification", teamId], data);
56+
},
57+
});
58+
59+
// Get the appropriate bottom text based on verification status
60+
const getBottomText = () => {
61+
if (!domainQuery.data) {
62+
return "Domains must be verified before use.";
63+
}
64+
65+
if (domainQuery.data.status === "pending") {
66+
return "Your domain verification is pending. Please add the DNS record to complete verification.";
67+
}
68+
69+
return `Domain ${domainQuery.data.domain} has been successfully verified.`;
70+
};
71+
72+
// Render the content for the settings card
73+
const renderContent = () => {
74+
// Initial state - show domain input form
75+
if (!domainQuery.data) {
76+
return (
77+
<div>
78+
<Input
79+
id="domain"
80+
placeholder="example.com"
81+
value={domain}
82+
onChange={(e) => setDomain(e.target.value)}
83+
disabled={!isOwnerAccount || verificationMutation.isPending}
84+
// is the max length for a domain
85+
maxLength={253}
86+
className="md:w-[450px]"
87+
/>
88+
<p className="mt-2 text-muted-foreground text-sm">
89+
Enter the domain you want to verify. Do not include http(s):// or
90+
www.
91+
</p>
92+
</div>
93+
);
94+
}
95+
96+
// Pending verification state
97+
if (domainQuery.data.status === "pending") {
98+
return (
99+
<div className="space-y-6">
100+
<div>
101+
<div className="flex items-center justify-between">
102+
<h3 className="font-medium text-sm">Domain</h3>
103+
<span className="inline-flex items-center rounded-full bg-yellow-100 px-2.5 py-0.5 font-medium text-xs text-yellow-800">
104+
Pending
105+
</span>
106+
</div>
107+
<p className="mt-1 font-medium">{domainQuery.data.domain}</p>
108+
</div>
109+
110+
<div className="space-y-4">
111+
<p>
112+
Before we can verify <b>{domainQuery.data.domain}</b>, you need to
113+
add the following DNS TXT record:
114+
</p>
115+
116+
<div className="grid gap-4">
117+
<div>
118+
<h4 className="mb-1 text-muted-foreground text-xs">
119+
Name / Host / Alias
120+
</h4>
121+
122+
<div className="flex items-center gap-2 break-all font-mono text-sm">
123+
<span className="flex items-center gap-2 rounded-md bg-muted p-2">
124+
{domainQuery.data.dnsSublabel}{" "}
125+
<CopyButton text={domainQuery.data.dnsSublabel} />
126+
</span>
127+
<span>.{domainQuery.data.domain}</span>
128+
</div>
129+
</div>
130+
131+
<div>
132+
<h4 className="mb-1 text-muted-foreground text-xs">
133+
Value / Content
134+
</h4>
135+
<div className="flex items-center gap-2 break-all font-mono text-sm">
136+
<span className="flex items-center gap-2 rounded-md bg-muted p-2">
137+
{domainQuery.data.dnsValue}{" "}
138+
<CopyButton text={domainQuery.data.dnsValue} />
139+
</span>
140+
</div>
141+
</div>
142+
</div>
143+
144+
<Alert variant="info">
145+
<AlertCircle className="size-4" />
146+
<AlertTitle>
147+
DNS changes can take up to 48 hours to propagate.
148+
</AlertTitle>
149+
<AlertDescription>
150+
We'll automatically check the status periodically. You can
151+
manually check the status by clicking the button below.
152+
</AlertDescription>
153+
</Alert>
154+
155+
<Button
156+
onClick={() => domainQuery.refetch()}
157+
disabled={domainQuery.isFetching}
158+
variant="outline"
159+
size="sm"
160+
className="flex items-center gap-2"
161+
>
162+
{domainQuery.isFetching && <Spinner className="size-4" />}
163+
{domainQuery.isFetching
164+
? "Checking Status..."
165+
: "Check Status Now"}
166+
</Button>
167+
</div>
168+
</div>
169+
);
170+
}
171+
172+
// Verified state
173+
return (
174+
<div>
175+
<div className="flex items-center justify-between">
176+
<div className="mt-2 flex items-start space-x-3">
177+
<CheckCircle className="mt-0.5 h-5 w-5 text-green-500" />
178+
<div>
179+
<p className="font-medium">{domainQuery.data.domain}</p>
180+
<p className="text-muted-foreground text-sm">
181+
Verified on{" "}
182+
{new Date(domainQuery.data.verifiedAt).toLocaleDateString()}
183+
</p>
184+
</div>
185+
</div>
186+
<span className="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 font-medium text-green-800 text-xs">
187+
Verified
188+
</span>
189+
</div>
190+
</div>
191+
);
192+
};
193+
194+
return (
195+
<SettingsCard
196+
header={{
197+
title: "Domain Verification",
198+
description: "Verify your domain to enable advanced features.",
199+
}}
200+
errorText={
201+
verificationMutation.error?.message || domainQuery.error?.message
202+
}
203+
noPermissionText={
204+
domainQuery.data?.status === "pending"
205+
? !isOwnerAccount
206+
? "Only team owners can verify domains"
207+
: undefined
208+
: undefined
209+
}
210+
bottomText={getBottomText()}
211+
saveButton={
212+
!domainQuery.data
213+
? {
214+
onClick: () =>
215+
verificationMutation.mutate({
216+
teamId,
217+
domain,
218+
}),
219+
disabled: !domain || verificationMutation.isPending,
220+
isPending: verificationMutation.isPending,
221+
label: "Verify Domain",
222+
}
223+
: undefined
224+
}
225+
>
226+
{renderContent()}
227+
</SettingsCard>
228+
);
229+
}

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ function Story() {
4242
leaveTeam={async () => {
4343
await new Promise((resolve) => setTimeout(resolve, 1000));
4444
}}
45+
initialVerification={null}
46+
isOwnerAccount={true}
4547
/>
4648
<ComponentVariants />
4749
</div>

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPage.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { apiServerProxy } from "@/actions/proxies";
44
import type { Team } from "@/api/team";
5+
import type { VerifiedDomainResponse } from "@/api/verified-domain";
56
import { useDashboardRouter } from "@/lib/DashboardRouter";
67
import type { ThirdwebClient } from "thirdweb";
78
import { upload } from "thirdweb/storage";
@@ -10,6 +11,8 @@ import { updateTeam } from "./updateTeam";
1011

1112
export function TeamGeneralSettingsPage(props: {
1213
team: Team;
14+
initialVerification: VerifiedDomainResponse | null;
15+
isOwnerAccount: boolean;
1316
client: ThirdwebClient;
1417
accountId: string;
1518
}) {
@@ -72,6 +75,8 @@ export function TeamGeneralSettingsPage(props: {
7275

7376
router.refresh();
7477
}}
78+
initialVerification={props.initialVerification}
79+
isOwnerAccount={props.isOwnerAccount}
7580
/>
7681
);
7782
}

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx

+10
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import type { Team } from "@/api/team";
4+
import type { VerifiedDomainResponse } from "@/api/verified-domain";
45
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
56
import { SettingsCard } from "@/components/blocks/SettingsCard";
67
import { CopyTextButton } from "@/components/ui/CopyTextButton";
@@ -12,12 +13,15 @@ import { FileInput } from "components/shared/FileInput";
1213
import { useState } from "react";
1314
import { toast } from "sonner";
1415
import type { ThirdwebClient } from "thirdweb";
16+
import { TeamDomainVerificationCard } from "../_components/settings-cards/domain-verification";
1517
import { teamSlugRegex } from "./common";
1618

1719
type UpdateTeamField = (team: Partial<Team>) => Promise<void>;
1820

1921
export function TeamGeneralSettingsPageUI(props: {
2022
team: Team;
23+
initialVerification: VerifiedDomainResponse | null;
24+
isOwnerAccount: boolean;
2125
updateTeamImage: (file: File | undefined) => Promise<void>;
2226
updateTeamField: UpdateTeamField;
2327
client: ThirdwebClient;
@@ -40,6 +44,12 @@ export function TeamGeneralSettingsPageUI(props: {
4044
client={props.client}
4145
/>
4246
<TeamIdCard team={props.team} />
47+
<TeamDomainVerificationCard
48+
teamId={props.team.id}
49+
initialVerification={props.initialVerification}
50+
isOwnerAccount={props.isOwnerAccount}
51+
/>
52+
4353
<LeaveTeamCard teamName={props.team.name} leaveTeam={props.leaveTeam} />
4454
<DeleteTeamCard
4555
enabled={hasPermissionToDelete}

0 commit comments

Comments
 (0)