mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-01 00:06:38 +00:00
Merge branch 'Fix/domain-picker-issue' of github.com:Pallavikumarimdb/pangolin into Pallavikumarimdb-Fix/domain-picker-issue
This commit is contained in:
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
59
src/lib/subdomain-utils.ts
Normal file
59
src/lib/subdomain-utils.ts
Normal 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));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user