Merge branch 'Fix/domain-picker-issue' of github.com:Pallavikumarimdb/pangolin into Pallavikumarimdb-Fix/domain-picker-issue

This commit is contained in:
Owen
2025-08-30 15:22:02 -07:00
4 changed files with 180 additions and 107 deletions

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
export const subdomainSchema = z export const subdomainSchema = z
.string() .string()
.regex( .regex(
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/, /^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/,
"Invalid subdomain format" "Invalid subdomain format"
) )
.min(1, "Subdomain must be at least 1 character long") .min(1, "Subdomain must be at least 1 character long")
@@ -12,7 +12,8 @@ export const subdomainSchema = z
export const tlsNameSchema = z export const tlsNameSchema = z
.string() .string()
.regex( .regex(
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$|^$/, /^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$|^$/,
"Invalid subdomain format" "Invalid subdomain format"
) )
.transform((val) => val.toLowerCase()); .transform((val) => val.toLowerCase());

View File

@@ -53,6 +53,7 @@ import {
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { Globe } from "lucide-react"; import { Globe } from "lucide-react";
import { build } from "@server/build"; import { build } from "@server/build";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
export default function GeneralForm() { export default function GeneralForm() {
const [formKey, setFormKey] = useState(0); const [formKey, setFormKey] = useState(0);
@@ -85,6 +86,7 @@ export default function GeneralForm() {
domainId: string; domainId: string;
subdomain?: string; subdomain?: string;
fullDomain: string; fullDomain: string;
baseDomain: string;
} | null>(null); } | null>(null);
const GeneralFormSchema = z const GeneralFormSchema = z
@@ -441,7 +443,8 @@ export default function GeneralForm() {
const selected = { const selected = {
domainId: res.domainId, domainId: res.domainId,
subdomain: res.subdomain, subdomain: res.subdomain,
fullDomain: res.fullDomain fullDomain: res.fullDomain,
baseDomain: res.baseDomain
}; };
setSelectedDomain(selected); setSelectedDomain(selected);
}} }}
@@ -454,18 +457,24 @@ export default function GeneralForm() {
<Button <Button
onClick={() => { onClick={() => {
if (selectedDomain) { if (selectedDomain) {
setResourceFullDomain( const sanitizedSubdomain = selectedDomain.subdomain
selectedDomain.fullDomain ? finalizeSubdomainSanitize(selectedDomain.subdomain)
); : "";
form.setValue(
"domainId", const sanitizedFullDomain = sanitizedSubdomain
selectedDomain.domainId ? `${sanitizedSubdomain}.${selectedDomain.baseDomain}`
); : selectedDomain.baseDomain;
form.setValue(
"subdomain", setResourceFullDomain(sanitizedFullDomain);
selectedDomain.subdomain form.setValue("domainId", selectedDomain.domainId);
); form.setValue("subdomain", sanitizedSubdomain);
setEditDomainOpen(false); setEditDomainOpen(false);
toast({
title: "Domain sanitized",
description: `Final domain: ${sanitizedFullDomain}`,
});
} }
}} }}
> >

View File

@@ -37,6 +37,12 @@ import { cn } from "@/lib/cn";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { build } from "@server/build"; import { build } from "@server/build";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
sanitizeInputRaw,
finalizeSubdomainSanitize,
validateByDomainType,
isValidSubdomainStructure
} from "@/lib/subdomain-utils";
type OrganizationDomain = { type OrganizationDomain = {
domainId: string; domainId: string;
@@ -255,108 +261,64 @@ export default function DomainPicker2({
const dropdownOptions = generateDropdownOptions(); const dropdownOptions = generateDropdownOptions();
const validateSubdomain = ( const finalizeSubdomain = (sub: string, base: DomainOption): string => {
subdomain: string, const sanitized = finalizeSubdomainSanitize(sub);
baseDomain: DomainOption
): boolean => {
if (!baseDomain) return false;
if (baseDomain.type === "provided-search") { if (!sanitized) {
return /^[a-zA-Z0-9-]+$/.test(subdomain); toast({
variant: "destructive",
title: "Invalid subdomain",
description: `The input "${sub}" was removed because it's not valid.`,
});
return "";
} }
if (baseDomain.type === "organization") { const ok = validateByDomainType(sanitized, {
if (baseDomain.domainType === "cname") { type: base.type === "provided-search" ? "provided-search" : "organization",
return subdomain === ""; domainType: base.domainType
} else if (baseDomain.domainType === "ns") { });
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain);
} else if (baseDomain.domainType === "wildcard") { if (!ok) {
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain); toast({
} variant: "destructive",
title: "Invalid subdomain",
description: `"${sub}" could not be made valid for ${base.domain}.`,
});
return "";
} }
return false; if (sub !== sanitized) {
}; toast({
title: "Subdomain sanitized",
// Handle base domain selection description: `"${sub}" was corrected to "${sanitized}"`,
const handleBaseDomainSelect = (option: DomainOption) => {
setSelectedBaseDomain(option);
setOpen(false);
if (option.domainType === "cname") {
setSubdomainInput("");
}
if (option.type === "provided-search") {
setUserInput("");
setAvailableOptions([]);
setSelectedProvidedDomain(null);
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: option.domain,
baseDomain: option.domain
}); });
} }
if (option.type === "organization") { return sanitized;
if (option.domainType === "cname") {
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: option.domain,
baseDomain: option.domain
});
} else {
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: option.domain,
baseDomain: option.domain
});
}
}
}; };
const handleSubdomainChange = (value: string) => { const handleSubdomainChange = (value: string) => {
const validInput = value.replace(/[^a-zA-Z0-9.-]/g, ""); const raw = sanitizeInputRaw(value);
setSubdomainInput(validInput); setSubdomainInput(raw);
setSelectedProvidedDomain(null); setSelectedProvidedDomain(null);
if (selectedBaseDomain && selectedBaseDomain.type === "organization") { if (selectedBaseDomain?.type === "organization") {
const isValid = validateSubdomain(validInput, selectedBaseDomain); const fullDomain = raw
if (isValid) { ? `${raw}.${selectedBaseDomain.domain}`
const fullDomain = validInput : selectedBaseDomain.domain;
? `${validInput}.${selectedBaseDomain.domain}`
: selectedBaseDomain.domain; onDomainChange?.({
onDomainChange?.({ domainId: selectedBaseDomain.domainId!,
domainId: selectedBaseDomain.domainId!, type: "organization",
type: "organization", subdomain: raw || undefined,
subdomain: validInput || undefined, fullDomain,
fullDomain: fullDomain, baseDomain: selectedBaseDomain.domain
baseDomain: selectedBaseDomain.domain });
});
} else if (validInput === "") {
onDomainChange?.({
domainId: selectedBaseDomain.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: selectedBaseDomain.domain,
baseDomain: selectedBaseDomain.domain
});
}
} }
}; };
const handleProvidedDomainInputChange = (value: string) => { const handleProvidedDomainInputChange = (value: string) => {
const validInput = value.replace(/[^a-zA-Z0-9.-]/g, ""); setUserInput(value);
setUserInput(validInput);
// Clear selected domain when user types
if (selectedProvidedDomain) { if (selectedProvidedDomain) {
setSelectedProvidedDomain(null); setSelectedProvidedDomain(null);
onDomainChange?.({ onDomainChange?.({
@@ -369,6 +331,38 @@ export default function DomainPicker2({
} }
}; };
const handleBaseDomainSelect = (option: DomainOption) => {
let sub = subdomainInput;
sub = finalizeSubdomain(sub, option);
setSubdomainInput(sub);
if (option.type === "provided-search") {
setUserInput("");
setAvailableOptions([]);
setSelectedProvidedDomain(null);
}
setSelectedBaseDomain(option);
setOpen(false);
if (option.domainType === "cname") {
sub = "";
setSubdomainInput("");
}
const fullDomain = sub ? `${sub}.${option.domain}` : option.domain;
onDomainChange?.({
domainId: option.domainId || "",
domainNamespaceId: option.domainNamespaceId,
type: option.type === "provided-search" ? "provided" : "organization",
subdomain: sub || undefined,
fullDomain,
baseDomain: option.domain
});
};
const handleProvidedDomainSelect = (option: AvailableOption) => { const handleProvidedDomainSelect = (option: AvailableOption) => {
setSelectedProvidedDomain(option); setSelectedProvidedDomain(option);
@@ -380,15 +374,19 @@ export default function DomainPicker2({
domainId: option.domainId, domainId: option.domainId,
domainNamespaceId: option.domainNamespaceId, domainNamespaceId: option.domainNamespaceId,
type: "provided", type: "provided",
subdomain: subdomain, subdomain,
fullDomain: option.fullDomain, fullDomain: option.fullDomain,
baseDomain: baseDomain baseDomain
}); });
}; };
const isSubdomainValid = selectedBaseDomain const isSubdomainValid = selectedBaseDomain && subdomainInput
? validateSubdomain(subdomainInput, selectedBaseDomain) ? validateByDomainType(subdomainInput, {
type: selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization",
domainType: selectedBaseDomain.domainType
})
: true; : true;
const showSubdomainInput = const showSubdomainInput =
selectedBaseDomain && selectedBaseDomain &&
selectedBaseDomain.type === "organization" && selectedBaseDomain.type === "organization" &&
@@ -396,7 +394,7 @@ export default function DomainPicker2({
const showProvidedDomainSearch = const showProvidedDomainSearch =
selectedBaseDomain?.type === "provided-search"; selectedBaseDomain?.type === "provided-search";
const sortedAvailableOptions = availableOptions.sort((a, b) => { const sortedAvailableOptions = [...availableOptions].sort((a, b) => {
const comparison = a.fullDomain.localeCompare(b.fullDomain); const comparison = a.fullDomain.localeCompare(b.fullDomain);
return sortOrder === "asc" ? comparison : -comparison; return sortOrder === "asc" ? comparison : -comparison;
}); });
@@ -434,8 +432,8 @@ export default function DomainPicker2({
} }
className={cn( className={cn(
!isSubdomainValid && !isSubdomainValid &&
subdomainInput && subdomainInput &&
"border-red-500" "border-red-500 focus:border-red-500"
)} )}
onChange={(e) => { onChange={(e) => {
if (showProvidedDomainSearch) { if (showProvidedDomainSearch) {
@@ -445,6 +443,12 @@ export default function DomainPicker2({
} }
}} }}
/> />
{showSubdomainInput && subdomainInput && !isValidSubdomainStructure(subdomainInput) && (
<p className="text-sm text-red-500">
This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.
</p>
)}
{showSubdomainInput && !subdomainInput && ( {showSubdomainInput && !subdomainInput && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("domainPickerEnterSubdomainOrLeaveBlank")} {t("domainPickerEnterSubdomainOrLeaveBlank")}

View File

@@ -0,0 +1,59 @@
export type DomainType = "organization" | "provided" | "provided-search";
export const SINGLE_LABEL_RE = /^[a-z0-9-]+$/i; // provided-search (no dots)
export const MULTI_LABEL_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)*$/i; // ns/wildcard
export const SINGLE_LABEL_STRICT_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i; // start/end alnum
export function sanitizeInputRaw(input: string): string {
if (!input) return "";
return input.toLowerCase().replace(/[^a-z0-9.-]/g, "");
}
export function finalizeSubdomainSanitize(input: string): string {
if (!input) return "";
return input
.toLowerCase()
.replace(/[^a-z0-9.-]/g, "") // allow only valid chars
.replace(/\.{2,}/g, ".") // collapse multiple dots
.replace(/^-+|-+$/g, "") // strip leading/trailing hyphens
.replace(/^\.+|\.+$/g, ""); // strip leading/trailing dots
}
export function validateByDomainType(subdomain: string, domainType: { type: "provided-search" | "organization"; domainType?: "ns" | "cname" | "wildcard" } ): boolean {
if (!domainType) return false;
if (domainType.type === "provided-search") {
return SINGLE_LABEL_RE.test(subdomain);
}
if (domainType.type === "organization") {
if (domainType.domainType === "cname") {
return subdomain === "";
} else if (domainType.domainType === "ns" || domainType.domainType === "wildcard") {
if (subdomain === "") return true;
if (!MULTI_LABEL_RE.test(subdomain)) return false;
const labels = subdomain.split(".");
return labels.every(l => l.length >= 1 && l.length <= 63 && SINGLE_LABEL_RE.test(l));
}
}
return false;
}
export const isValidSubdomainStructure = (input: string): boolean => {
const regex = /^(?!-)([a-zA-Z0-9-]{1,63})(?<!-)$/;
if (!input) return false;
if (input.includes("..")) return false;
return input.split(".").every(label => regex.test(label));
};