Skip to content

Commit ea755f1

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

File tree

12 files changed

+378
-9
lines changed

12 files changed

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

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)