2025-07-13 21:57:24 -07:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
import {
|
|
|
|
AlertCircle,
|
|
|
|
CheckCircle2,
|
|
|
|
Building2,
|
|
|
|
Zap,
|
|
|
|
ArrowUpDown
|
|
|
|
} from "lucide-react";
|
|
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
|
|
import { createApiClient, formatAxiosError } from "@/lib/api";
|
|
|
|
import { useEnvContext } from "@/hooks/useEnvContext";
|
|
|
|
import { toast } from "@/hooks/useToast";
|
|
|
|
import { ListDomainsResponse } from "@server/routers/domain/listDomains";
|
|
|
|
import { AxiosResponse } from "axios";
|
|
|
|
import { cn } from "@/lib/cn";
|
|
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
import { useTranslations } from "next-intl";
|
|
|
|
|
|
|
|
type OrganizationDomain = {
|
|
|
|
domainId: string;
|
|
|
|
baseDomain: string;
|
|
|
|
verified: boolean;
|
2025-07-14 12:24:38 -07:00
|
|
|
type: "ns" | "cname" | "wildcard";
|
2025-07-13 21:57:24 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
type AvailableOption = {
|
|
|
|
domainNamespaceId: string;
|
|
|
|
fullDomain: string;
|
|
|
|
domainId: string;
|
|
|
|
};
|
|
|
|
|
|
|
|
type DomainOption = {
|
|
|
|
id: string;
|
|
|
|
domain: string;
|
|
|
|
type: "organization" | "provided";
|
|
|
|
verified?: boolean;
|
2025-07-14 12:24:38 -07:00
|
|
|
domainType?: "ns" | "cname" | "wildcard";
|
2025-07-13 21:57:24 -07:00
|
|
|
domainId?: string;
|
|
|
|
domainNamespaceId?: string;
|
|
|
|
subdomain?: string;
|
|
|
|
};
|
|
|
|
|
|
|
|
interface DomainPickerProps {
|
|
|
|
orgId: string;
|
|
|
|
onDomainChange?: (domainInfo: {
|
|
|
|
domainId: string;
|
|
|
|
domainNamespaceId?: string;
|
|
|
|
type: "organization" | "provided";
|
|
|
|
subdomain?: string;
|
|
|
|
fullDomain: string;
|
|
|
|
baseDomain: string;
|
|
|
|
}) => void;
|
|
|
|
}
|
|
|
|
|
|
|
|
export default function DomainPicker({
|
|
|
|
orgId,
|
|
|
|
onDomainChange
|
|
|
|
}: DomainPickerProps) {
|
|
|
|
const { env } = useEnvContext();
|
|
|
|
const api = createApiClient({ env });
|
|
|
|
const t = useTranslations();
|
|
|
|
|
|
|
|
const [userInput, setUserInput] = useState<string>("");
|
|
|
|
const [selectedOption, setSelectedOption] = useState<DomainOption | null>(
|
|
|
|
null
|
|
|
|
);
|
|
|
|
const [availableOptions, setAvailableOptions] = useState<AvailableOption[]>(
|
|
|
|
[]
|
|
|
|
);
|
|
|
|
const [isChecking, setIsChecking] = useState(false);
|
|
|
|
const [organizationDomains, setOrganizationDomains] = useState<
|
|
|
|
OrganizationDomain[]
|
|
|
|
>([]);
|
|
|
|
const [loadingDomains, setLoadingDomains] = useState(false);
|
|
|
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
|
|
|
const [activeTab, setActiveTab] = useState<
|
|
|
|
"all" | "organization" | "provided"
|
|
|
|
>("all");
|
|
|
|
const [providedDomainsShown, setProvidedDomainsShown] = useState(3);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
const loadOrganizationDomains = async () => {
|
|
|
|
setLoadingDomains(true);
|
|
|
|
try {
|
|
|
|
const response = await api.get<
|
|
|
|
AxiosResponse<ListDomainsResponse>
|
|
|
|
>(`/org/${orgId}/domains`);
|
|
|
|
if (response.status === 200) {
|
|
|
|
const domains = response.data.data.domains
|
|
|
|
.filter(
|
|
|
|
(domain) =>
|
2025-07-14 12:24:38 -07:00
|
|
|
domain.type === "ns" || domain.type === "cname" || domain.type === "wildcard"
|
2025-07-13 21:57:24 -07:00
|
|
|
)
|
|
|
|
.map((domain) => ({
|
|
|
|
...domain,
|
2025-07-14 12:24:38 -07:00
|
|
|
type: domain.type as "ns" | "cname" | "wildcard"
|
2025-07-13 21:57:24 -07:00
|
|
|
}));
|
|
|
|
setOrganizationDomains(domains);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.error("Failed to load organization domains:", error);
|
|
|
|
toast({
|
|
|
|
variant: "destructive",
|
|
|
|
title: "Error",
|
|
|
|
description: "Failed to load organization domains"
|
|
|
|
});
|
|
|
|
} finally {
|
|
|
|
setLoadingDomains(false);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
loadOrganizationDomains();
|
|
|
|
}, [orgId, api]);
|
|
|
|
|
|
|
|
// Generate domain options based on user input
|
|
|
|
const generateDomainOptions = (): DomainOption[] => {
|
|
|
|
const options: DomainOption[] = [];
|
|
|
|
|
|
|
|
if (!userInput.trim()) return options;
|
|
|
|
|
|
|
|
// Check if input is more than one level deep (contains multiple dots)
|
|
|
|
const isMultiLevel = (userInput.match(/\./g) || []).length > 1;
|
|
|
|
|
|
|
|
// Add organization domain options
|
|
|
|
organizationDomains.forEach((orgDomain) => {
|
|
|
|
if (orgDomain.type === "cname") {
|
|
|
|
// For CNAME domains, check if the user input matches exactly
|
|
|
|
if (
|
|
|
|
orgDomain.baseDomain.toLowerCase() ===
|
|
|
|
userInput.toLowerCase()
|
|
|
|
) {
|
|
|
|
options.push({
|
|
|
|
id: `org-${orgDomain.domainId}`,
|
|
|
|
domain: orgDomain.baseDomain,
|
|
|
|
type: "organization",
|
|
|
|
verified: orgDomain.verified,
|
|
|
|
domainType: "cname",
|
|
|
|
domainId: orgDomain.domainId
|
|
|
|
});
|
|
|
|
}
|
|
|
|
} else if (orgDomain.type === "ns") {
|
|
|
|
// For NS domains, check if the user input could be a subdomain
|
|
|
|
const userInputLower = userInput.toLowerCase();
|
|
|
|
const baseDomainLower = orgDomain.baseDomain.toLowerCase();
|
|
|
|
|
|
|
|
// Check if user input ends with the base domain
|
|
|
|
if (userInputLower.endsWith(`.${baseDomainLower}`)) {
|
|
|
|
const subdomain = userInputLower.slice(
|
|
|
|
0,
|
|
|
|
-(baseDomainLower.length + 1)
|
|
|
|
);
|
|
|
|
options.push({
|
|
|
|
id: `org-${orgDomain.domainId}`,
|
|
|
|
domain: userInput,
|
|
|
|
type: "organization",
|
|
|
|
verified: orgDomain.verified,
|
|
|
|
domainType: "ns",
|
|
|
|
domainId: orgDomain.domainId,
|
|
|
|
subdomain: subdomain
|
|
|
|
});
|
|
|
|
} else if (userInputLower === baseDomainLower) {
|
|
|
|
// Exact match for base domain
|
|
|
|
options.push({
|
|
|
|
id: `org-${orgDomain.domainId}`,
|
|
|
|
domain: orgDomain.baseDomain,
|
|
|
|
type: "organization",
|
|
|
|
verified: orgDomain.verified,
|
|
|
|
domainType: "ns",
|
|
|
|
domainId: orgDomain.domainId
|
|
|
|
});
|
|
|
|
}
|
2025-07-14 12:24:38 -07:00
|
|
|
} else if (orgDomain.type === "wildcard") {
|
|
|
|
// For wildcard domains, allow the base domain or one level up
|
|
|
|
const userInputLower = userInput.toLowerCase();
|
|
|
|
const baseDomainLower = orgDomain.baseDomain.toLowerCase();
|
|
|
|
|
|
|
|
// Check if user input is exactly the base domain
|
|
|
|
if (userInputLower === baseDomainLower) {
|
|
|
|
options.push({
|
|
|
|
id: `org-${orgDomain.domainId}`,
|
|
|
|
domain: orgDomain.baseDomain,
|
|
|
|
type: "organization",
|
|
|
|
verified: orgDomain.verified,
|
|
|
|
domainType: "wildcard",
|
|
|
|
domainId: orgDomain.domainId
|
|
|
|
});
|
|
|
|
}
|
|
|
|
// Check if user input is one level up (subdomain.baseDomain)
|
|
|
|
else if (userInputLower.endsWith(`.${baseDomainLower}`)) {
|
|
|
|
const subdomain = userInputLower.slice(
|
|
|
|
0,
|
|
|
|
-(baseDomainLower.length + 1)
|
|
|
|
);
|
|
|
|
// Only allow one level up (no dots in subdomain)
|
|
|
|
if (!subdomain.includes('.')) {
|
|
|
|
options.push({
|
|
|
|
id: `org-${orgDomain.domainId}`,
|
|
|
|
domain: userInput,
|
|
|
|
type: "organization",
|
|
|
|
verified: orgDomain.verified,
|
|
|
|
domainType: "wildcard",
|
|
|
|
domainId: orgDomain.domainId,
|
|
|
|
subdomain: subdomain
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
2025-07-13 21:57:24 -07:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Add provided domain options (always try to match provided domains)
|
|
|
|
availableOptions.forEach((option) => {
|
|
|
|
options.push({
|
|
|
|
id: `provided-${option.domainNamespaceId}`,
|
|
|
|
domain: option.fullDomain,
|
|
|
|
type: "provided",
|
|
|
|
domainNamespaceId: option.domainNamespaceId,
|
|
|
|
domainId: option.domainId,
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
// Sort options
|
|
|
|
return options.sort((a, b) => {
|
|
|
|
const comparison = a.domain.localeCompare(b.domain);
|
|
|
|
return sortOrder === "asc" ? comparison : -comparison;
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
const domainOptions = generateDomainOptions();
|
|
|
|
|
|
|
|
// Filter options based on active tab
|
|
|
|
const filteredOptions = domainOptions.filter((option) => {
|
|
|
|
if (activeTab === "all") return true;
|
|
|
|
return option.type === activeTab;
|
|
|
|
});
|
|
|
|
|
|
|
|
// Separate organization and provided options for pagination
|
|
|
|
const organizationOptions = filteredOptions.filter(
|
|
|
|
(opt) => opt.type === "organization"
|
|
|
|
);
|
|
|
|
const allProvidedOptions = filteredOptions.filter(
|
|
|
|
(opt) => opt.type === "provided"
|
|
|
|
);
|
|
|
|
const providedOptions = allProvidedOptions.slice(0, providedDomainsShown);
|
|
|
|
const hasMoreProvided = allProvidedOptions.length > providedDomainsShown;
|
|
|
|
|
|
|
|
// Handle option selection
|
|
|
|
const handleOptionSelect = (option: DomainOption) => {
|
|
|
|
setSelectedOption(option);
|
|
|
|
|
|
|
|
if (option.type === "organization") {
|
|
|
|
if (option.domainType === "cname") {
|
|
|
|
onDomainChange?.({
|
|
|
|
domainId: option.domainId!,
|
|
|
|
type: "organization",
|
|
|
|
subdomain: undefined,
|
|
|
|
fullDomain: option.domain,
|
|
|
|
baseDomain: option.domain
|
|
|
|
});
|
|
|
|
} else if (option.domainType === "ns") {
|
|
|
|
const subdomain = option.subdomain || "";
|
|
|
|
onDomainChange?.({
|
|
|
|
domainId: option.domainId!,
|
|
|
|
type: "organization",
|
|
|
|
subdomain: subdomain || undefined,
|
|
|
|
fullDomain: option.domain,
|
|
|
|
baseDomain: option.domain
|
|
|
|
});
|
2025-07-14 12:24:38 -07:00
|
|
|
} else if (option.domainType === "wildcard") {
|
|
|
|
onDomainChange?.({
|
|
|
|
domainId: option.domainId!,
|
|
|
|
type: "organization",
|
|
|
|
subdomain: option.subdomain || undefined,
|
|
|
|
fullDomain: option.domain,
|
|
|
|
baseDomain: option.subdomain
|
|
|
|
? option.domain.split('.').slice(1).join('.')
|
|
|
|
: option.domain
|
|
|
|
});
|
2025-07-13 21:57:24 -07:00
|
|
|
}
|
|
|
|
} else if (option.type === "provided") {
|
|
|
|
// Extract subdomain from full domain
|
|
|
|
const parts = option.domain.split(".");
|
|
|
|
const subdomain = parts[0];
|
|
|
|
const baseDomain = parts.slice(1).join(".");
|
|
|
|
onDomainChange?.({
|
|
|
|
domainId: option.domainId!,
|
|
|
|
domainNamespaceId: option.domainNamespaceId,
|
|
|
|
type: "provided",
|
|
|
|
subdomain: subdomain,
|
|
|
|
fullDomain: option.domain,
|
|
|
|
baseDomain: baseDomain
|
|
|
|
});
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className="space-y-6">
|
|
|
|
{/* Domain Input */}
|
|
|
|
<div className="space-y-2">
|
|
|
|
<Label htmlFor="domain-input">
|
|
|
|
{t("domainPickerEnterDomain")}
|
|
|
|
</Label>
|
|
|
|
<Input
|
|
|
|
id="domain-input"
|
|
|
|
value={userInput}
|
|
|
|
onChange={(e) => {
|
|
|
|
// Only allow letters, numbers, hyphens, and periods
|
|
|
|
const validInput = e.target.value.replace(
|
|
|
|
/[^a-zA-Z0-9.-]/g,
|
|
|
|
""
|
|
|
|
);
|
|
|
|
setUserInput(validInput);
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
{t("domainPickerDescription")}
|
|
|
|
</p>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
{/* Tabs and Sort Toggle */}
|
|
|
|
<div className="flex justify-between items-center">
|
|
|
|
<Tabs
|
|
|
|
value={activeTab}
|
|
|
|
onValueChange={(value) =>
|
|
|
|
setActiveTab(
|
|
|
|
value as "all" | "organization" | "provided"
|
|
|
|
)
|
|
|
|
}
|
|
|
|
>
|
|
|
|
<TabsList>
|
|
|
|
<TabsTrigger value="all">
|
|
|
|
{t("domainPickerTabAll")}
|
|
|
|
</TabsTrigger>
|
|
|
|
<TabsTrigger value="organization">
|
|
|
|
{t("domainPickerTabOrganization")}
|
|
|
|
</TabsTrigger>
|
|
|
|
<TabsTrigger value="provided">
|
|
|
|
{t("domainPickerTabProvided")}
|
|
|
|
</TabsTrigger>
|
|
|
|
</TabsList>
|
|
|
|
</Tabs>
|
|
|
|
<Button
|
|
|
|
variant="outline"
|
|
|
|
size="sm"
|
|
|
|
onClick={() =>
|
|
|
|
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
|
|
|
|
}
|
|
|
|
>
|
|
|
|
<ArrowUpDown className="h-4 w-4 mr-2" />
|
|
|
|
{sortOrder === "asc"
|
|
|
|
? t("domainPickerSortAsc")
|
|
|
|
: t("domainPickerSortDesc")}
|
|
|
|
</Button>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
{/* Loading State */}
|
|
|
|
{isChecking && (
|
|
|
|
<div className="flex items-center justify-center p-8">
|
|
|
|
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
|
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
|
|
|
<span>{t("domainPickerCheckingAvailability")}</span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
|
|
|
|
{/* No Options */}
|
|
|
|
{!isChecking &&
|
|
|
|
filteredOptions.length === 0 &&
|
|
|
|
userInput.trim() && (
|
|
|
|
<Alert>
|
|
|
|
<AlertCircle className="h-4 w-4" />
|
|
|
|
<AlertDescription>
|
|
|
|
{t("domainPickerNoMatchingDomains", { userInput })}
|
|
|
|
</AlertDescription>
|
|
|
|
</Alert>
|
|
|
|
)}
|
|
|
|
|
|
|
|
{/* Domain Options */}
|
|
|
|
{!isChecking && filteredOptions.length > 0 && (
|
|
|
|
<div className="space-y-4">
|
|
|
|
{/* Organization Domains */}
|
|
|
|
{organizationOptions.length > 0 && (
|
|
|
|
<div className="space-y-3">
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
<Building2 className="h-4 w-4" />
|
|
|
|
<h4 className="text-sm font-medium">
|
|
|
|
{t("domainPickerOrganizationDomains")}
|
|
|
|
</h4>
|
|
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
|
|
{organizationOptions.map((option) => (
|
|
|
|
<div
|
|
|
|
key={option.id}
|
|
|
|
className={cn(
|
|
|
|
"transition-all p-3 rounded-lg border",
|
|
|
|
selectedOption?.id === option.id
|
|
|
|
? "border-primary bg-primary/5"
|
|
|
|
: "border-input",
|
|
|
|
option.verified
|
|
|
|
? "cursor-pointer hover:bg-accent"
|
|
|
|
: "cursor-not-allowed opacity-60"
|
|
|
|
)}
|
|
|
|
onClick={() =>
|
|
|
|
option.verified && handleOptionSelect(option)
|
|
|
|
}
|
|
|
|
>
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
<div className="flex-1">
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
<p className="font-mono text-sm">
|
|
|
|
{option.domain}
|
|
|
|
</p>
|
|
|
|
{/* <Badge */}
|
|
|
|
{/* variant={ */}
|
|
|
|
{/* option.domainType === */}
|
|
|
|
{/* "ns" */}
|
|
|
|
{/* ? "default" */}
|
|
|
|
{/* : "secondary" */}
|
|
|
|
{/* } */}
|
|
|
|
{/* > */}
|
|
|
|
{/* {option.domainType} */}
|
|
|
|
{/* </Badge> */}
|
|
|
|
{option.verified ? (
|
|
|
|
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
|
|
|
) : (
|
|
|
|
<AlertCircle className="h-3 w-3 text-yellow-500" />
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
{option.subdomain && (
|
|
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
|
|
{t(
|
|
|
|
"domainPickerSubdomain",
|
|
|
|
{
|
|
|
|
subdomain:
|
|
|
|
option.subdomain
|
|
|
|
}
|
|
|
|
)}
|
|
|
|
</p>
|
|
|
|
)}
|
|
|
|
{!option.verified && (
|
|
|
|
<p className="text-xs text-yellow-600 mt-1">
|
|
|
|
Domain is unverified
|
|
|
|
</p>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
{selectedOption?.id ===
|
|
|
|
option.id && (
|
|
|
|
<CheckCircle2 className="h-4 w-4 text-primary" />
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
|
|
|
|
{/* Provided Domains */}
|
|
|
|
{providedOptions.length > 0 && (
|
|
|
|
<div className="space-y-3">
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
<Zap className="h-4 w-4" />
|
|
|
|
<div className="text-sm font-medium">
|
|
|
|
{t("domainPickerProvidedDomains")}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
|
|
{providedOptions.map((option) => (
|
|
|
|
<div
|
|
|
|
key={option.id}
|
|
|
|
className={cn(
|
|
|
|
"transition-all p-3 rounded-lg border",
|
|
|
|
selectedOption?.id === option.id
|
|
|
|
? "border-primary bg-primary/5"
|
|
|
|
: "border-input",
|
|
|
|
"cursor-pointer hover:bg-accent"
|
|
|
|
)}
|
|
|
|
onClick={() =>
|
|
|
|
handleOptionSelect(option)
|
|
|
|
}
|
|
|
|
>
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
<div>
|
|
|
|
<p className="font-mono text-sm">
|
|
|
|
{option.domain}
|
|
|
|
</p>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
{t(
|
|
|
|
"domainPickerNamespace",
|
|
|
|
{
|
|
|
|
namespace:
|
|
|
|
option.domainNamespaceId as string
|
|
|
|
}
|
|
|
|
)}
|
|
|
|
</p>
|
|
|
|
</div>
|
|
|
|
{selectedOption?.id ===
|
|
|
|
option.id && (
|
|
|
|
<CheckCircle2 className="h-4 w-4 text-primary" />
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
{hasMoreProvided && (
|
|
|
|
<Button
|
|
|
|
variant="outline"
|
|
|
|
size="sm"
|
|
|
|
onClick={() =>
|
|
|
|
setProvidedDomainsShown(
|
|
|
|
(prev) => prev + 3
|
|
|
|
)
|
|
|
|
}
|
|
|
|
className="w-full"
|
|
|
|
>
|
|
|
|
{t("domainPickerShowMore")}
|
|
|
|
</Button>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function debounce<T extends (...args: any[]) => any>(
|
|
|
|
func: T,
|
|
|
|
wait: number
|
|
|
|
): (...args: Parameters<T>) => void {
|
|
|
|
let timeout: NodeJS.Timeout | null = null;
|
|
|
|
|
|
|
|
return (...args: Parameters<T>) => {
|
|
|
|
if (timeout) clearTimeout(timeout);
|
|
|
|
|
|
|
|
timeout = setTimeout(() => {
|
|
|
|
func(...args);
|
|
|
|
}, wait);
|
|
|
|
};
|
|
|
|
}
|