fosrl.pangolin/src/components/DomainPicker.tsx

551 lines
24 KiB
TypeScript
Raw Normal View History

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";
import { build } from "@server/build";
2025-07-13 21:57:24 -07:00
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) =>
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(".")) {
2025-07-14 12:24:38 -07:00
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
2025-07-13 21:57:24 -07:00
});
});
// 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(".")
2025-07-14 12:24:38 -07:00
: 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>
{build == "saas" && (
<TabsTrigger value="provided">
{t("domainPickerTabProvided")}
</TabsTrigger>
)}
2025-07-13 21:57:24 -07:00
</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)
2025-07-13 21:57:24 -07:00
}
>
<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);
};
}