"use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { PlusIcon, XIcon } from "lucide-react"; import { type Dispatch, type SetStateAction, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { createSamlConfig, updateSamlConfig } from "@/actions/integrations"; import { AddIcon } from "@/components/icons"; import { Button, Badge, Card, CardContent, CardHeader, Field, FieldError, FieldLabel, Input, useToast, } from "@/components/shadcn"; import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet"; import { CustomInput } from "@/components/shadcn/custom"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { Form, FormButtons } from "@/components/shadcn/form"; import { useRuntimeConfig } from "@/hooks/use-runtime-config"; import { isCloud } from "@/lib/shared/env"; import { useCloudUpgradeStore } from "@/store"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { getSamlConfigFormSchema, samlAdditionalEmailDomainSchema, samlEmailDomainSchema, type SamlConfigFormInput, type SamlConfigFormValues, } from "@/types/formSchemas"; import { MAX_SAML_ADDITIONAL_EMAIL_DOMAINS, type SamlConfiguration, type SamlConfigurationErrors, } from "@/types/saml"; const validateXMLContent = ( xmlContent: string, ): { isValid: boolean; error?: string } => { try { // Basic checks if (!xmlContent || !xmlContent.trim()) { return { isValid: false, error: "XML content is empty.", }; } const trimmedContent = xmlContent.trim(); // Check if it starts and ends with XML tags if (!trimmedContent.startsWith("<") || !trimmedContent.endsWith(">")) { return { isValid: false, error: "Content does not appear to be valid XML format.", }; } // Use DOMParser to validate XML structure const parser = new DOMParser(); const xmlDoc = parser.parseFromString(xmlContent, "text/xml"); // Check for parser errors const parserError = xmlDoc.querySelector("parsererror"); if (parserError) { const errorText = parserError.textContent || "Unknown XML parsing error"; return { isValid: false, error: `XML parsing error: ${errorText.substring(0, 100)}...`, }; } // Check if the document has a root element if (!xmlDoc.documentElement) { return { isValid: false, error: "XML does not have a valid root element.", }; } // Optional: Check for basic SAML metadata structure const rootElement = xmlDoc.documentElement; const rootTagName = rootElement.tagName.toLowerCase(); // Check if it looks like SAML metadata (common root elements) const samlRootElements = [ "entitydescriptor", "entitiesDescriptor", "metadata", "md:entitydescriptor", "md:entitiesdescriptor", ]; const isSamlMetadata = samlRootElements.some((element) => rootTagName.includes(element.toLowerCase()), ); if (!isSamlMetadata) { // Check for common SAML namespace attributes const xmlString = xmlContent.toLowerCase(); const hasSamlNamespace = xmlString.includes("saml") || xmlString.includes("urn:oasis:names:tc:saml") || xmlString.includes("metadata"); if (!hasSamlNamespace) { return { isValid: false, error: "The XML file does not appear to be SAML metadata. Please ensure you're uploading the correct SAML metadata file from your Identity Provider.", }; } } return { isValid: true }; } catch (error) { return { isValid: false, error: error instanceof Error ? error.message : "Failed to validate XML content.", }; } }; export const SamlConfigForm = ({ setIsOpen, samlConfig, }: { setIsOpen: Dispatch>; samlConfig?: SamlConfiguration; }) => { const isUpdate = Boolean(samlConfig?.id); const formSchema = getSamlConfigFormSchema(isUpdate); const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { email_domain: samlConfig?.attributes?.email_domain ?? "", additional_email_domains: samlConfig?.attributes?.additional_email_domains ?? [], metadata_xml: "", }, }); const emailDomain = form.watch("email_domain"); const additionalEmailDomains = form.watch("additional_email_domains") ?? []; const isPending = form.formState.isSubmitting; const [additionalDomainDraft, setAdditionalDomainDraft] = useState(""); const [uploadedFile, setUploadedFile] = useState(null); // Local state needed: submission must wait for the selected metadata file. const [isMetadataFileReading, setIsMetadataFileReading] = useState(false); const activeFileReaderRef = useRef(null); const { toast } = useToast(); const isCloudEnv = isCloud(); const openCloudUpgrade = useCloudUpgradeStore( (storeState) => storeState.openCloudUpgrade, ); const additionalDomainsError = form.formState.errors.additional_email_domains?.message; const setAdditionalDomainsError = (message: string) => { form.setError("additional_email_domains", { type: "manual", message, }); }; const addAdditionalDomain = () => { const validation = samlAdditionalEmailDomainSchema.safeParse( additionalDomainDraft, ); if (!validation.success) { setAdditionalDomainsError( validation.error.issues[0]?.message ?? "Additional email domain is invalid", ); return; } const normalizedDomain = validation.data; if (normalizedDomain === emailDomain.trim().toLowerCase()) { setAdditionalDomainsError( "An additional domain must differ from the primary email domain.", ); return; } if (additionalEmailDomains.includes(normalizedDomain)) { setAdditionalDomainsError("This domain has already been added."); return; } if (additionalEmailDomains.length >= MAX_SAML_ADDITIONAL_EMAIL_DOMAINS) { setAdditionalDomainsError( `A SAML configuration supports up to ${MAX_SAML_ADDITIONAL_EMAIL_DOMAINS} additional email domains.`, ); return; } form.setValue( "additional_email_domains", [...additionalEmailDomains, normalizedDomain], { shouldDirty: true }, ); setAdditionalDomainDraft(""); form.clearErrors("additional_email_domains"); }; const removeAdditionalDomain = (domainToRemove: string) => { form.setValue( "additional_email_domains", additionalEmailDomains.filter((domain) => domain !== domainToRemove), { shouldDirty: true }, ); form.clearErrors("additional_email_domains"); }; const invalidateActiveFileRead = () => { const activeReader = activeFileReaderRef.current; activeFileReaderRef.current = null; activeReader?.abort(); }; const clearMetadataFile = (fileInput: HTMLInputElement) => { invalidateActiveFileRead(); fileInput.value = ""; form.setValue("metadata_xml", "", { shouldDirty: true, shouldValidate: form.formState.isSubmitted, }); setIsMetadataFileReading(false); setUploadedFile(null); }; const handleFileUpload = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) { clearMetadataFile(event.target); return; } // Check file extension const isXmlFile = file.name.toLowerCase().endsWith(".xml") || file.type === "text/xml" || file.type === "application/xml"; if (!isXmlFile) { toast({ variant: "destructive", title: "Invalid file type", description: "Please select a valid XML file (.xml extension).", }); clearMetadataFile(event.target); return; } invalidateActiveFileRead(); const reader = new FileReader(); activeFileReaderRef.current = reader; setIsMetadataFileReading(true); reader.onload = (e) => { if (activeFileReaderRef.current !== reader) { return; } activeFileReaderRef.current = null; setIsMetadataFileReading(false); const content = typeof e.target?.result === "string" ? e.target.result : ""; // Comprehensive XML validation const xmlValidationResult = validateXMLContent(content); if (!xmlValidationResult.isValid) { toast({ variant: "destructive", title: "Invalid XML content", description: xmlValidationResult.error, }); clearMetadataFile(event.target); return; } form.setValue("metadata_xml", content, { shouldDirty: true, shouldValidate: true, }); setUploadedFile(file); toast({ title: "File uploaded successfully", description: "XML metadata file has been loaded.", }); }; reader.onerror = () => { if (activeFileReaderRef.current !== reader) { return; } activeFileReaderRef.current = null; toast({ variant: "destructive", title: "File read error", description: "Failed to read the selected file.", }); clearMetadataFile(event.target); }; reader.readAsText(file); }; const setFieldErrors = (errors: SamlConfigurationErrors) => { if (errors.email_domain) { form.setError("email_domain", { type: "server", message: errors.email_domain, }); } if (errors.additional_email_domains) { form.setError("additional_email_domains", { type: "server", message: errors.additional_email_domains, }); } if (errors.metadata_xml) { form.setError("metadata_xml", { type: "server", message: errors.metadata_xml, }); } }; const onSubmit = async (values: SamlConfigFormValues) => { const pendingDomains = additionalDomainDraft.trim() ? [...values.additional_email_domains, additionalDomainDraft] : values.additional_email_domains; const validatedSubmission = formSchema.safeParse({ ...values, additional_email_domains: pendingDomains, }); if (!validatedSubmission.success) { const fieldErrors = validatedSubmission.error.flatten().fieldErrors; setFieldErrors({ email_domain: fieldErrors.email_domain?.[0], additional_email_domains: fieldErrors.additional_email_domains?.[0], metadata_xml: fieldErrors.metadata_xml?.[0], }); return; } const formData = new FormData(); if (samlConfig?.id) { formData.set("id", samlConfig.id); } formData.set("email_domain", validatedSubmission.data.email_domain); validatedSubmission.data.additional_email_domains.forEach((domain) => formData.append("additional_email_domains", domain), ); formData.set("metadata_xml", validatedSubmission.data.metadata_xml); try { const result = isUpdate ? await updateSamlConfig(null, formData) : await createSamlConfig(null, formData); if (result?.success) { toast({ title: "Configuration saved successfully", description: result.success, }); setIsOpen(false); return; } if (result?.errors) { setFieldErrors(result.errors); if (result.errors.general) { toast({ variant: "destructive", title: "Oops! Something went wrong", description: result.errors.general, }); } } } catch { toast({ variant: "destructive", title: "Oops! Something went wrong", description: "Unable to save the SAML configuration. Please try again.", }); } }; const { apiBaseUrl } = useRuntimeConfig(); const normalizedEmailDomain = samlEmailDomainSchema.safeParse(emailDomain); const acsEmailDomain = normalizedEmailDomain.success ? normalizedEmailDomain.data : ""; const acsUrl = acsEmailDomain && apiBaseUrl ? `${apiBaseUrl}/accounts/saml/${acsEmailDomain}/acs/` : ""; return (
Need help configuring SAML SSO?{" "} Read the docs
Additional Email Domains {isCloudEnv ? ( {additionalEmailDomains.length} /{" "} {MAX_SAML_ADDITIONAL_EMAIL_DOMAINS} domains ) : ( )}

Allow users from other verified domains to use this same SAML configuration. The ACS URL always uses the primary domain.

{isCloudEnv && ( <>
= MAX_SAML_ADDITIONAL_EMAIL_DOMAINS } aria-invalid={Boolean(additionalDomainsError) || undefined} onChange={(event) => { setAdditionalDomainDraft(event.target.value); form.clearErrors("additional_email_domains"); }} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addAdditionalDomain(); } }} />
{additionalDomainsError && ( {additionalDomainsError} )} {additionalEmailDomains.length > 0 && (
    {additionalEmailDomains.map((domain) => (
  • {domain}
  • ))}
)} {additionalEmailDomains.map((domain) => ( ))} )}
Identity Provider Configuration
ACS URL:
Audience:
Name ID Format: urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
Supported Assertion Attributes:
  • • firstName
  • • lastName
  • • userType
  • • organization

Note: The userType attribute will be used to assign the user's role. If the role does not exist, one will be created with minimal permissions. You can assign permissions to roles on the{" "} Roles {" "} page.

Metadata XML File{" "} {!isUpdate && *}

Upload your Identity Provider's SAML metadata XML file

{form.formState.errors.metadata_xml?.message}
); };