From 721bf3403d471de184b6540a574c593d189a1678 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 15 Oct 2025 10:21:00 -0700 Subject: [PATCH 1/2] fix form --- messages/en-US.json | 2 +- src/components/GenerateLicenseKeyForm.tsx | 2025 +++++++++------------ 2 files changed, 887 insertions(+), 1140 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 09b8734d..585feea8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1839,7 +1839,7 @@ "companyPhoneNumber": "Company phone number", "country": "Country", "phoneNumberOptional": "Phone number (optional)", - "complianceConfirmation": "I confirm that I am in compliance with the Fossorial Commercial License and that reporting inaccurate information or misidentifying use of the product is a violation of the license." + "complianceConfirmation": "I confirm that the information I provided is accurate and that I am in compliance with the Fossorial Commercial License. Reporting inaccurate information or misidentifying use of the product is a violation of the license and may result in your key getting revoked." }, "buttons": { "close": "Close", diff --git a/src/components/GenerateLicenseKeyForm.tsx b/src/components/GenerateLicenseKeyForm.tsx index 17d593c0..6cc17fe6 100644 --- a/src/components/GenerateLicenseKeyForm.tsx +++ b/src/components/GenerateLicenseKeyForm.tsx @@ -36,7 +36,7 @@ import { useTranslations } from "next-intl"; import React from "react"; import { StrategySelect, StrategyOption } from "./StrategySelect"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; -import { InfoIcon, Check } from "lucide-react"; +import { InfoIcon, Check, Loader2 } from "lucide-react"; import { useUserContext } from "@app/hooks/useUserContext"; type FormProps = { @@ -58,342 +58,72 @@ export default function GenerateLicenseKeyForm({ const { user } = useUserContext(); - const [currentStep, setCurrentStep] = useState(1); const [loading, setLoading] = useState(false); const [generatedKey, setGeneratedKey] = useState(null); - const [formKey, setFormKey] = useState(0); - // Step 1: Email & License Type - const step1Schema = z.object({ - email: z - .string() - .email(t("generateLicenseKeyForm.validation.emailRequired")), - useCaseType: z.enum(["personal", "business"], { - required_error: t( - "generateLicenseKeyForm.validation.useCaseTypeRequired" - ) - }) + // Personal form schema + const personalFormSchema = z.object({ + email: z.string().email(), + firstName: z.string().min(1), + lastName: z.string().min(1), + primaryUse: z.string().min(1), + stateProvinceRegion: z.string().min(1), + postalZipCode: z.string().min(1), + country: z.string().min(1), + phoneNumber: z.string().optional(), + agreedToTerms: z.boolean().refine((val) => val === true), + complianceConfirmed: z.boolean().refine((val) => val === true) }); - // Step 2: Personal Information - const createStep2Schema = (useCaseType: string | undefined) => - z - .object({ - firstName: z - .string() - .min( - 1, - t("generateLicenseKeyForm.validation.firstNameRequired") - ), - lastName: z - .string() - .min( - 1, - t("generateLicenseKeyForm.validation.lastNameRequired") - ), - jobTitle: z.string().optional(), - primaryUse: z - .string() - .min( - 1, - t( - "generateLicenseKeyForm.validation.primaryUseRequired" - ) - ), - industry: z.string().optional(), - prospectiveUsers: z.coerce.number().optional(), - prospectiveSites: z.coerce.number().optional() - }) - .refine( - (data) => { - // If business use case, job title is required - if (useCaseType === "business") { - return data.jobTitle; - } - return true; - }, - { - message: t( - "generateLicenseKeyForm.validation.jobTitleRequiredBusiness" - ), - path: ["jobTitle"] - } - ) - .refine( - (data) => { - // If business use case, industry is required - if (useCaseType === "business") { - return data.industry; - } - return true; - }, - { - message: t( - "generateLicenseKeyForm.validation.industryRequiredBusiness" - ), - path: ["industry"] - } - ); - - // Step 3: Contact Information - const createStep3Schema = (useCaseType: string | undefined) => - z - .object({ - stateProvinceRegion: z - .string() - .min( - 1, - t( - "generateLicenseKeyForm.validation.stateProvinceRegionRequired" - ) - ), - postalZipCode: z - .string() - .min( - 1, - t( - "generateLicenseKeyForm.validation.postalZipCodeRequired" - ) - ), - country: z.string().optional(), - phoneNumber: z.string().optional(), - companyName: z.string().optional(), - countryOfResidence: z.string().optional(), - companyWebsite: z.string().optional(), - companyPhoneNumber: z.string().optional() - }) - .refine( - (data) => { - // If business use case, company name is required - if (useCaseType === "business") { - return data.companyName; - } - return true; - }, - { - message: t( - "generateLicenseKeyForm.validation.companyNameRequiredBusiness" - ), - path: ["companyName"] - } - ) - .refine( - (data) => { - // If business use case, country of residence is required - if (useCaseType === "business") { - return data.countryOfResidence; - } - return true; - }, - { - message: t( - "generateLicenseKeyForm.validation.countryOfResidenceRequiredBusiness" - ), - path: ["countryOfResidence"] - } - ) - .refine( - (data) => { - // If personal use case, country is required - if (useCaseType === "personal" && !data.country) { - return false; - } - return true; - }, - { - message: t( - "generateLicenseKeyForm.validation.countryRequiredPersonal" - ), - path: ["country"] - } - ); - - // Step 4: Terms & Generate - const step4Schema = z.object({ - agreedToTerms: z - .boolean() - .refine( - (val) => val === true, - t("generateLicenseKeyForm.validation.agreeToTermsRequired") - ), - complianceConfirmed: z - .boolean() - .refine( - (val) => val === true, - t("generateLicenseKeyForm.validation.complianceConfirmationRequired") - ) - }); - - // Complete form schema for final submission with conditional validation - const createFormSchema = (useCaseType: string | undefined) => - z - .object({ - email: z.string().email("Please enter a valid email address"), - useCaseType: z.enum(["personal", "business"]), - firstName: z.string().min(1, "First name is required"), - lastName: z.string().min(1, "Last name is required"), - jobTitle: z.string().optional(), - primaryUse: z - .string() - .min(1, "Please describe your primary use"), - industry: z.string().optional(), - prospectiveUsers: z.coerce.number().optional(), - prospectiveSites: z.coerce.number().optional(), - stateProvinceRegion: z - .string() - .min( - 1, - t( - "generateLicenseKeyForm.validation.stateProvinceRegionRequired" - ) - ), - postalZipCode: z - .string() - .min( - 1, - t( - "generateLicenseKeyForm.validation.postalZipCodeRequired" - ) - ), - country: z.string().optional(), - phoneNumber: z.string().optional(), - companyName: z.string().optional(), - countryOfResidence: z.string().optional(), - companyWebsite: z.string().optional(), - companyPhoneNumber: z.string().optional(), - agreedToTerms: z - .boolean() - .refine( - (val) => val === true, - t( - "generateLicenseKeyForm.validation.agreeToTermsRequired" - ) - ), - complianceConfirmed: z - .boolean() - .refine( - (val) => val === true, - t("generateLicenseKeyForm.validation.complianceConfirmationRequired") - ) - }) - .refine( - (data) => { - // If business use case, job title is required - if (useCaseType === "business") { - return data.jobTitle; - } - return true; - }, - { - message: t( - "generateLicenseKeyForm.validation.jobTitleRequiredBusiness" - ), - path: ["jobTitle"] - } - ) - .refine( - (data) => { - // If business use case, industry is required - if (useCaseType === "business") { - return data.industry; - } - return true; - }, - { - message: t( - "generateLicenseKeyForm.validation.industryRequiredBusiness" - ), - path: ["industry"] - } - ) - .refine( - (data) => { - // If business use case, company name is required - if (useCaseType === "business") { - return data.companyName; - } - return true; - }, - { - message: t( - "generateLicenseKeyForm.validation.companyNameRequiredBusiness" - ), - path: ["companyName"] - } - ) - .refine( - (data) => { - // If business use case, country of residence is required - if (useCaseType === "business") { - return data.countryOfResidence; - } - return true; - }, - { - message: t( - "generateLicenseKeyForm.validation.countryOfResidenceRequiredBusiness" - ), - path: ["countryOfResidence"] - } - ) - .refine( - (data) => { - // If personal use case, country is required - if (useCaseType === "personal") { - return data.country; - } - return true; - }, - { - message: t( - "generateLicenseKeyForm.validation.countryRequiredPersonal" - ), - path: ["country"] - } - ); - - type FormData = z.infer>; - - // Base schema for form initialization (without conditional validation) - const baseFormSchema = z.object({ - email: z.string().email("Please enter a valid email address"), - useCaseType: z.enum(["personal", "business"]), - firstName: z.string().min(1, "First name is required"), - lastName: z.string().min(1, "Last name is required"), - jobTitle: z.string().optional(), - primaryUse: z.string().min(1, "Please describe your primary use"), - industry: z.string().optional(), + // Business form schema + const businessFormSchema = z.object({ + email: z.string().email(), + firstName: z.string().min(1), + lastName: z.string().min(1), + jobTitle: z.string().min(1), + primaryUse: z.string().min(1), + industry: z.string().min(1), prospectiveUsers: z.coerce.number().optional(), prospectiveSites: z.coerce.number().optional(), - stateProvinceRegion: z - .string() - .min(1, "State/Province/Region is required"), - postalZipCode: z.string().min(1, "Postal/ZIP Code is required"), - country: z.string().optional(), - phoneNumber: z.string().optional(), - companyName: z.string().optional(), - countryOfResidence: z.string().optional(), + companyName: z.string().min(1), + countryOfResidence: z.string().min(1), + stateProvinceRegion: z.string().min(1), + postalZipCode: z.string().min(1), companyWebsite: z.string().optional(), companyPhoneNumber: z.string().optional(), - agreedToTerms: z - .boolean() - .refine( - (val) => val === true, - t("generateLicenseKeyForm.validation.agreeToTermsRequired") - ), - complianceConfirmed: z - .boolean() - .refine( - (val) => val === true, - t("generateLicenseKeyForm.validation.complianceConfirmationRequired") - ) + agreedToTerms: z.boolean().refine((val) => val === true), + complianceConfirmed: z.boolean().refine((val) => val === true) }); - const form = useForm({ - resolver: zodResolver(baseFormSchema), + type PersonalFormData = z.infer; + type BusinessFormData = z.infer; + + const [useCaseType, setUseCaseType] = useState( + undefined + ); + + // Personal form + const personalForm = useForm({ + resolver: zodResolver(personalFormSchema), + defaultValues: { + email: user?.email || "", + firstName: "", + lastName: "", + primaryUse: "", + stateProvinceRegion: "", + postalZipCode: "", + country: "", + phoneNumber: "", + agreedToTerms: false, + complianceConfirmed: false + } + }); + + // Business form + const businessForm = useForm({ + resolver: zodResolver(businessFormSchema), defaultValues: { email: user?.email || "", - useCaseType: undefined, firstName: "", lastName: "", jobTitle: "", @@ -401,12 +131,10 @@ export default function GenerateLicenseKeyForm({ industry: "", prospectiveUsers: undefined, prospectiveSites: undefined, - stateProvinceRegion: "", - postalZipCode: "", - country: "", - phoneNumber: "", companyName: "", countryOfResidence: "", + stateProvinceRegion: "", + postalZipCode: "", companyWebsite: "", companyPhoneNumber: "", agreedToTerms: false, @@ -414,62 +142,47 @@ export default function GenerateLicenseKeyForm({ } }); - const useCaseType = form.watch("useCaseType"); - const [previousUseCaseType, setPreviousUseCaseType] = useState< - string | undefined - >(undefined); - - // Reset form when use case type changes - React.useEffect(() => { - if ( - useCaseType !== previousUseCaseType && - useCaseType && - previousUseCaseType - ) { - // Reset fields that are specific to use case type - form.setValue("jobTitle", ""); - form.setValue("prospectiveUsers", undefined); - form.setValue("prospectiveSites", undefined); - form.setValue("companyName", ""); - form.setValue("countryOfResidence", ""); - form.setValue("companyWebsite", ""); - form.setValue("companyPhoneNumber", ""); - form.setValue("phoneNumber", ""); - form.setValue("country", ""); - - setPreviousUseCaseType(useCaseType); - } - }, [useCaseType, previousUseCaseType, form]); - // Reset form when dialog opens React.useEffect(() => { if (open) { - form.reset({ - email: user?.email || "", - useCaseType: undefined, - firstName: "", - lastName: "", - jobTitle: "", - primaryUse: "", - industry: "", - prospectiveUsers: undefined, - prospectiveSites: undefined, - stateProvinceRegion: "", - postalZipCode: "", - country: "", - phoneNumber: "", - companyName: "", - countryOfResidence: "", - companyWebsite: "", - companyPhoneNumber: "", - agreedToTerms: false, - complianceConfirmed: false - }); - setCurrentStep(1); + resetForm(); setGeneratedKey(null); - setPreviousUseCaseType(undefined); } - }, [open, form, user?.email]); + }, [open]); + + function resetForm() { + personalForm.reset({ + email: user?.email || "", + firstName: "", + lastName: "", + primaryUse: "", + stateProvinceRegion: "", + postalZipCode: "", + country: "", + phoneNumber: "", + agreedToTerms: false, + complianceConfirmed: false + }); + + businessForm.reset({ + email: user?.email || "", + firstName: "", + lastName: "", + jobTitle: "", + primaryUse: "", + industry: "", + prospectiveUsers: undefined, + prospectiveSites: undefined, + companyName: "", + countryOfResidence: "", + stateProvinceRegion: "", + postalZipCode: "", + companyWebsite: "", + companyPhoneNumber: "", + agreedToTerms: false, + complianceConfirmed: false + }); + } const useCaseOptions: StrategyOption<"personal" | "business">[] = [ { @@ -540,161 +253,9 @@ export default function GenerateLicenseKeyForm({ } ]; - const steps = [ - { - title: t("generateLicenseKeyForm.steps.emailLicenseType.title"), - description: t( - "generateLicenseKeyForm.steps.emailLicenseType.description" - ) - }, - { - title: t("generateLicenseKeyForm.steps.personalInformation.title"), - description: t( - "generateLicenseKeyForm.steps.personalInformation.description" - ) - }, - { - title: t("generateLicenseKeyForm.steps.contactInformation.title"), - description: t( - "generateLicenseKeyForm.steps.contactInformation.description" - ) - }, - { - title: t("generateLicenseKeyForm.steps.termsGenerate.title"), - description: t( - "generateLicenseKeyForm.steps.termsGenerate.description" - ) - } - ]; - - const nextStep = async () => { - let isValid = false; - - try { - // Validate current step based on step number - switch (currentStep) { - case 1: - await step1Schema.parseAsync(form.getValues()); - isValid = true; - break; - case 2: - await createStep2Schema( - form.getValues("useCaseType") - ).parseAsync(form.getValues()); - isValid = true; - break; - case 3: - await createStep3Schema( - form.getValues("useCaseType") - ).parseAsync(form.getValues()); - isValid = true; - break; - case 4: - await step4Schema.parseAsync(form.getValues()); - isValid = true; - break; - default: - isValid = false; - } - } catch (error) { - if (error instanceof z.ZodError) { - // Set form errors for the current step fields - error.errors.forEach((err) => { - const fieldName = err.path[0] as keyof FormData; - form.setError(fieldName, { - type: "manual", - message: err.message - }); - }); - } - return; - } - - if (isValid && currentStep < steps.length) { - setCurrentStep(currentStep + 1); - } - }; - - const prevStep = () => { - if (currentStep > 1) { - setCurrentStep(currentStep - 1); - } - }; - - const onSubmit = async (values: FormData) => { - // Validate with the dynamic schema before submission - try { - await createFormSchema(values.useCaseType).parseAsync(values); - } catch (error) { - if (error instanceof z.ZodError) { - // Set form errors for any validation failures - error.errors.forEach((err) => { - const fieldName = err.path[0] as keyof FormData; - form.setError(fieldName, { - type: "manual", - message: err.message - }); - }); - return; - } - } - + const submitLicenseRequest = async (payload: any) => { setLoading(true); try { - const payload = { - email: values.email, - useCaseType: values.useCaseType, - personal: - values.useCaseType === "personal" - ? { - firstName: values.firstName, - lastName: values.lastName, - aboutYou: { - primaryUse: values.primaryUse - }, - personalInfo: { - stateProvinceRegion: - values.stateProvinceRegion, - postalZipCode: values.postalZipCode, - country: values.country, - phoneNumber: values.phoneNumber || "" - } - } - : undefined, - business: - values.useCaseType === "business" - ? { - firstName: values.firstName, - lastName: values.lastName, - jobTitle: values.jobTitle || "", - aboutYou: { - primaryUse: values.primaryUse, - industry: values.industry, - prospectiveUsers: - values.prospectiveUsers || undefined, - prospectiveSites: - values.prospectiveSites || undefined - }, - companyInfo: { - companyName: values.companyName || "", - countryOfResidence: - values.countryOfResidence || "", - stateProvinceRegion: - values.stateProvinceRegion, - postalZipCode: values.postalZipCode, - companyWebsite: values.companyWebsite || "", - companyPhoneNumber: - values.companyPhoneNumber || "" - } - } - : undefined, - consent: { - agreedToTerms: values.agreedToTerms, - acknowledgedPrivacyPolicy: values.agreedToTerms, - complianceConfirmed: values.complianceConfirmed - } - }; - const response = await api.put< AxiosResponse >(`/org/${orgId}/license`, payload); @@ -724,546 +285,72 @@ export default function GenerateLicenseKeyForm({ setLoading(false); }; - const handleClose = () => { - setOpen(false); - setCurrentStep(1); - setGeneratedKey(null); - setFormKey((prev) => prev + 1); // Force form reset by changing key - form.reset({ - email: user?.email || "", - useCaseType: undefined, - firstName: "", - lastName: "", - jobTitle: "", - primaryUse: "", - industry: "", - prospectiveUsers: undefined, - prospectiveSites: undefined, - stateProvinceRegion: "", - postalZipCode: "", - country: "", - phoneNumber: "", - companyName: "", - countryOfResidence: "", - companyWebsite: "", - companyPhoneNumber: "", - agreedToTerms: false, - complianceConfirmed: false - }); + const onSubmitPersonal = async (values: PersonalFormData) => { + const payload = { + email: values.email, + useCaseType: "personal", + personal: { + firstName: values.firstName, + lastName: values.lastName, + aboutYou: { + primaryUse: values.primaryUse + }, + personalInfo: { + stateProvinceRegion: values.stateProvinceRegion, + postalZipCode: values.postalZipCode, + country: values.country, + phoneNumber: values.phoneNumber || "" + } + }, + business: undefined, + consent: { + agreedToTerms: values.agreedToTerms, + acknowledgedPrivacyPolicy: values.agreedToTerms, + complianceConfirmed: values.complianceConfirmed + } + }; + + await submitLicenseRequest(payload); }; - const renderStepContent = () => { - switch (currentStep) { - case 1: - return ( -
- - - - {t( - "generateLicenseKeyForm.alerts.commercialUseDisclosure.title" - )} - - - {t( - "generateLicenseKeyForm.alerts.commercialUseDisclosure.description" - ).split("Fossorial Commercial License Terms").map((part, index) => ( - - {part} - {index === 0 && ( - - Fossorial Commercial License Terms - - )} - - ))} - - + const onSubmitBusiness = async (values: BusinessFormData) => { + const payload = { + email: values.email, + useCaseType: "business", + personal: undefined, + business: { + firstName: values.firstName, + lastName: values.lastName, + jobTitle: values.jobTitle, + aboutYou: { + primaryUse: values.primaryUse, + industry: values.industry, + prospectiveUsers: values.prospectiveUsers || undefined, + prospectiveSites: values.prospectiveSites || undefined + }, + companyInfo: { + companyName: values.companyName, + countryOfResidence: values.countryOfResidence, + stateProvinceRegion: values.stateProvinceRegion, + postalZipCode: values.postalZipCode, + companyWebsite: values.companyWebsite || "", + companyPhoneNumber: values.companyPhoneNumber || "" + } + }, + consent: { + agreedToTerms: values.agreedToTerms, + acknowledgedPrivacyPolicy: values.agreedToTerms, + complianceConfirmed: values.complianceConfirmed + } + }; - ( - - - {t( - "generateLicenseKeyForm.form.useCaseQuestion" - )} - - { - field.onChange(value); - // Reset form when use case type changes - form.reset({ - email: user?.email || "", - useCaseType: value, - firstName: "", - lastName: "", - jobTitle: "", - primaryUse: "", - industry: "", - prospectiveUsers: undefined, - prospectiveSites: undefined, - stateProvinceRegion: "", - postalZipCode: "", - country: "", - phoneNumber: "", - companyName: "", - countryOfResidence: "", - companyWebsite: "", - companyPhoneNumber: "", - agreedToTerms: false, - complianceConfirmed: false - }); - }} - cols={2} - /> - - - )} - /> -
- ); + await submitLicenseRequest(payload); + }; - case 2: - return ( -
-
- ( - - - {t( - "generateLicenseKeyForm.form.firstName" - )} - - - - - - - )} - /> - - ( - - - {t( - "generateLicenseKeyForm.form.lastName" - )} - - - - - - - )} - /> -
- - {useCaseType === "business" && ( - ( - - - {t( - "generateLicenseKeyForm.form.jobTitle" - )} - - - - - - - )} - /> - )} - -
- ( - - - {t( - "generateLicenseKeyForm.form.primaryUseQuestion" - )} - - - - - - - )} - /> - - {useCaseType === "business" && ( - <> - ( - - - {t( - "generateLicenseKeyForm.form.industryQuestion" - )} - - - - - - - )} - /> - - ( - - - {t( - "generateLicenseKeyForm.form.prospectiveUsersQuestion" - )} - - - - - - - )} - /> - - ( - - - {t( - "generateLicenseKeyForm.form.prospectiveSitesQuestion" - )} - - - - - - - )} - /> - - )} -
-
- ); - - case 3: - return ( -
- {useCaseType === "business" && ( -
- ( - - - {t( - "generateLicenseKeyForm.form.companyName" - )} - - - - - - - )} - /> - - ( - - - {t( - "generateLicenseKeyForm.form.countryOfResidence" - )} - - - - - - - )} - /> - -
- ( - - - {t( - "generateLicenseKeyForm.form.stateProvinceRegion" - )} - - - - - - - )} - /> - - ( - - - {t( - "generateLicenseKeyForm.form.postalZipCode" - )} - - - - - - - )} - /> -
- -
- ( - - - {t( - "generateLicenseKeyForm.form.companyWebsite" - )} - - - - - - - )} - /> - - ( - - - {t( - "generateLicenseKeyForm.form.companyPhoneNumber" - )} - - - - - - - )} - /> -
-
- )} - - {useCaseType === "personal" && ( -
-
- ( - - - {t( - "generateLicenseKeyForm.form.stateProvinceRegion" - )} - - - - - - - )} - /> - - ( - - - {t( - "generateLicenseKeyForm.form.postalZipCode" - )} - - - - - - - )} - /> -
- -
- ( - - - {t( - "generateLicenseKeyForm.form.country" - )} - - - - - - - )} - /> - - ( - - - {t( - "generateLicenseKeyForm.form.phoneNumberOptional" - )} - - - - - - - )} - /> -
-
- )} -
- ); - - case 4: - return ( -
- ( - - - - -
- -
- {t("signUpTerms.IAgreeToThe")}{" "} - - {t( - "signUpTerms.termsOfService" - )}{" "} - - {t("signUpTerms.and")}{" "} - - {t( - "signUpTerms.privacyPolicy" - )} - -
-
- -
-
- )} - /> - - ( - - - - -
- -
- I confirm that I am in compliance with the{" "} - - Fossorial Commercial License - {" "} - and that reporting inaccurate information or misidentifying use of the product is a violation of the license. -
-
- -
-
- )} - /> -
- ); - - default: - return null; - } + const handleClose = () => { + setOpen(false); + setGeneratedKey(null); + resetForm(); }; return ( @@ -1272,40 +359,13 @@ export default function GenerateLicenseKeyForm({ {t("generateLicenseKey")} - {steps[currentStep - 1]?.description} + {t( + "generateLicenseKeyForm.steps.emailLicenseType.description" + )}
- {/* Progress indicator */} -
- {steps.map((step, index) => ( -
-
- {index + 1} -
- - {step.title} - -
- ))} -
- {generatedKey ? (
{useCaseType === "business" && ( @@ -1329,14 +389,713 @@ export default function GenerateLicenseKeyForm({ />
) : ( -
- - {renderStepContent()} -
- + <> + + + + {t( + "generateLicenseKeyForm.alerts.commercialUseDisclosure.title" + )} + + + {t( + "generateLicenseKeyForm.alerts.commercialUseDisclosure.description" + ) + .split( + "Fossorial Commercial License Terms" + ) + .map((part, index) => ( + + {part} + {index === 0 && ( + + Fossorial Commercial + License Terms + + )} + + ))} + + + +
+ +
+ { + setUseCaseType(value); + resetForm(); + }} + cols={2} + /> +
+
+ + {useCaseType === "personal" && ( +
+ +
+ ( + + + {t( + "generateLicenseKeyForm.form.firstName" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.lastName" + )} + + + + + + + )} + /> +
+ + ( + + + {t( + "generateLicenseKeyForm.form.primaryUseQuestion" + )} + + + + + + + )} + /> + +
+
+ ( + + + {t( + "generateLicenseKeyForm.form.stateProvinceRegion" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.postalZipCode" + )} + + + + + + + )} + /> +
+ +
+ ( + + + {t( + "generateLicenseKeyForm.form.country" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.phoneNumberOptional" + )} + + + + + + + )} + /> +
+
+ +
+ ( + + + + +
+ +
+ {t( + "signUpTerms.IAgreeToThe" + )}{" "} + + {t( + "signUpTerms.termsOfService" + )}{" "} + + {t( + "signUpTerms.and" + )}{" "} + + {t( + "signUpTerms.privacyPolicy" + )} + +
+
+ +
+
+ )} + /> + + ( + + + + +
+ +
+ {t( + "generateLicenseKeyForm.form.complianceConfirmation" + )}{" "} + See + license + details:{" "} + + https://digpangolin.com/fcl.html + +
+
+ +
+
+ )} + /> +
+ + + )} + + {useCaseType === "business" && ( +
+ +
+ ( + + + {t( + "generateLicenseKeyForm.form.firstName" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.lastName" + )} + + + + + + + )} + /> +
+ + ( + + + {t( + "generateLicenseKeyForm.form.jobTitle" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.primaryUseQuestion" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.industryQuestion" + )} + + + + + + + )} + /> + +
+ ( + + + {t( + "generateLicenseKeyForm.form.prospectiveUsersQuestion" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.prospectiveSitesQuestion" + )} + + + + + + + )} + /> +
+ + ( + + + {t( + "generateLicenseKeyForm.form.companyName" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.countryOfResidence" + )} + + + + + + + )} + /> + +
+ ( + + + {t( + "generateLicenseKeyForm.form.stateProvinceRegion" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.postalZipCode" + )} + + + + + + + )} + /> +
+ +
+ ( + + + {t( + "generateLicenseKeyForm.form.companyWebsite" + )} + + + + + + + )} + /> + + ( + + + {t( + "generateLicenseKeyForm.form.companyPhoneNumber" + )} + + + + + + + )} + /> +
+ +
+ ( + + + + +
+ +
+ {t( + "signUpTerms.IAgreeToThe" + )}{" "} + + {t( + "signUpTerms.termsOfService" + )}{" "} + + {t( + "signUpTerms.and" + )}{" "} + + {t( + "signUpTerms.privacyPolicy" + )} + +
+
+ +
+
+ )} + /> + + ( + + + + +
+ +
+ {t( + "generateLicenseKeyForm.form.complianceConfirmation" + )}{" "} + See + license + details:{" "} + + https://digpangolin.com/fcl.html + +
+
+ +
+
+ )} + /> +
+ + + )} + )}
@@ -1347,42 +1106,30 @@ export default function GenerateLicenseKeyForm({ - {!generatedKey && ( - <> - {currentStep > 1 && ( - + {!generatedKey && useCaseType === "personal" && ( + + )} - {currentStep < steps.length ? ( - - ) : ( - + {!generatedKey && useCaseType === "business" && ( + )} From 59ecab5738f5041510dc9a377a076c67c7d5434d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Oct 2025 10:39:18 -0700 Subject: [PATCH 2/2] Dont ping remote nodes; handle certs better --- install/get-installer.sh | 180 ++++++++++++++++++ server/private/lib/exitNodes/exitNodes.ts | 70 +++---- .../private/lib/traefik/getTraefikConfig.ts | 77 +++++++- 3 files changed, 282 insertions(+), 45 deletions(-) create mode 100644 install/get-installer.sh diff --git a/install/get-installer.sh b/install/get-installer.sh new file mode 100644 index 00000000..d7f684ce --- /dev/null +++ b/install/get-installer.sh @@ -0,0 +1,180 @@ +#!/bin/bash + +# Get installer - Cross-platform installation script +# Usage: curl -fsSL https://raw.githubusercontent.com/fosrl/installer/refs/heads/main/get-installer.sh | bash + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# GitHub repository info +REPO="fosrl/pangolin" +GITHUB_API_URL="https://api.github.com/repos/${REPO}/releases/latest" + +# Function to print colored output +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Function to get latest version from GitHub API +get_latest_version() { + local latest_info + + if command -v curl >/dev/null 2>&1; then + latest_info=$(curl -fsSL "$GITHUB_API_URL" 2>/dev/null) + elif command -v wget >/dev/null 2>&1; then + latest_info=$(wget -qO- "$GITHUB_API_URL" 2>/dev/null) + else + print_error "Neither curl nor wget is available. Please install one of them." >&2 + exit 1 + fi + + if [ -z "$latest_info" ]; then + print_error "Failed to fetch latest version information" >&2 + exit 1 + fi + + # Extract version from JSON response (works without jq) + local version=$(echo "$latest_info" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/') + + if [ -z "$version" ]; then + print_error "Could not parse version from GitHub API response" >&2 + exit 1 + fi + + # Remove 'v' prefix if present + version=$(echo "$version" | sed 's/^v//') + + echo "$version" +} + +# Detect OS and architecture +detect_platform() { + local os arch + + # Detect OS - only support Linux + case "$(uname -s)" in + Linux*) os="linux" ;; + *) + print_error "Unsupported operating system: $(uname -s). Only Linux is supported." + exit 1 + ;; + esac + + # Detect architecture - only support amd64 and arm64 + case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + *) + print_error "Unsupported architecture: $(uname -m). Only amd64 and arm64 are supported on Linux." + exit 1 + ;; + esac + + echo "${os}_${arch}" +} + +# Get installation directory +get_install_dir() { + # Install to the current directory + local install_dir="$(pwd)" + if [ ! -d "$install_dir" ]; then + print_error "Installation directory does not exist: $install_dir" + exit 1 + fi + echo "$install_dir" +} + +# Download and install installer +install_installer() { + local platform="$1" + local install_dir="$2" + local binary_name="installer_${platform}" + + local download_url="${BASE_URL}/${binary_name}" + local temp_file="/tmp/installer" + local final_path="${install_dir}/installer" + + print_status "Downloading installer from ${download_url}" + + # Download the binary + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$download_url" -o "$temp_file" + elif command -v wget >/dev/null 2>&1; then + wget -q "$download_url" -O "$temp_file" + else + print_error "Neither curl nor wget is available. Please install one of them." + exit 1 + fi + + # Create install directory if it doesn't exist + mkdir -p "$install_dir" + + # Move binary to install directory + mv "$temp_file" "$final_path" + + # Make executable + chmod +x "$final_path" + + print_status "Installer downloaded to ${final_path}" +} + +# Verify installation +verify_installation() { + local install_dir="$1" + local installer_path="${install_dir}/installer" + + if [ -f "$installer_path" ] && [ -x "$installer_path" ]; then + print_status "Installation successful!" + return 0 + else + print_error "Installation failed. Binary not found or not executable." + return 1 + fi +} + +# Main installation process +main() { + print_status "Installing latest version of installer..." + + # Get latest version + print_status "Fetching latest version from GitHub..." + VERSION=$(get_latest_version) + print_status "Latest version: v${VERSION}" + + # Set base URL with the fetched version + BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}" + + # Detect platform + PLATFORM=$(detect_platform) + print_status "Detected platform: ${PLATFORM}" + + # Get install directory + INSTALL_DIR=$(get_install_dir) + print_status "Install directory: ${INSTALL_DIR}" + + # Install installer + install_installer "$PLATFORM" "$INSTALL_DIR" + + # Verify installation + if verify_installation "$INSTALL_DIR"; then + print_status "Installer is ready to use!" + else + exit 1 + fi +} + +# Run main function +main "$@" \ No newline at end of file diff --git a/server/private/lib/exitNodes/exitNodes.ts b/server/private/lib/exitNodes/exitNodes.ts index ea83fe9d..0dad7714 100644 --- a/server/private/lib/exitNodes/exitNodes.ts +++ b/server/private/lib/exitNodes/exitNodes.ts @@ -183,47 +183,47 @@ export async function listExitNodes(orgId: string, filterOnline = false, noCloud return []; } - // Enhanced online checking: consider node offline if either DB says offline OR HTTP ping fails - const nodesWithRealOnlineStatus = await Promise.all( - allExitNodes.map(async (node) => { - // If database says it's online, verify with HTTP ping - let online: boolean; - if (filterOnline && node.type == "remoteExitNode") { - try { - const isActuallyOnline = await checkExitNodeOnlineStatus( - node.endpoint - ); + // // Enhanced online checking: consider node offline if either DB says offline OR HTTP ping fails + // const nodesWithRealOnlineStatus = await Promise.all( + // allExitNodes.map(async (node) => { + // // If database says it's online, verify with HTTP ping + // let online: boolean; + // if (filterOnline && node.type == "remoteExitNode") { + // try { + // const isActuallyOnline = await checkExitNodeOnlineStatus( + // node.endpoint + // ); - // set the item in the database if it is offline - if (isActuallyOnline != node.online) { - await db - .update(exitNodes) - .set({ online: isActuallyOnline }) - .where(eq(exitNodes.exitNodeId, node.exitNodeId)); - } - online = isActuallyOnline; - } catch (error) { - logger.warn( - `Failed to check online status for exit node ${node.name} (${node.endpoint}): ${error instanceof Error ? error.message : "Unknown error"}` - ); - online = false; - } - } else { - online = node.online; - } + // // set the item in the database if it is offline + // if (isActuallyOnline != node.online) { + // await db + // .update(exitNodes) + // .set({ online: isActuallyOnline }) + // .where(eq(exitNodes.exitNodeId, node.exitNodeId)); + // } + // online = isActuallyOnline; + // } catch (error) { + // logger.warn( + // `Failed to check online status for exit node ${node.name} (${node.endpoint}): ${error instanceof Error ? error.message : "Unknown error"}` + // ); + // online = false; + // } + // } else { + // online = node.online; + // } - return { - ...node, - online - }; - }) - ); + // return { + // ...node, + // online + // }; + // }) + // ); - const remoteExitNodes = nodesWithRealOnlineStatus.filter( + const remoteExitNodes = allExitNodes.filter( (node) => node.type === "remoteExitNode" && (!filterOnline || node.online) ); - const gerbilExitNodes = nodesWithRealOnlineStatus.filter( + const gerbilExitNodes = allExitNodes.filter( (node) => node.type === "gerbil" && (!filterOnline || node.online) && !noCloud ); diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index f6d1c8ab..e09af0df 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -90,13 +90,24 @@ export async function getTraefikConfig( exitNodeId: sites.exitNodeId, // Namespace domainNamespaceId: domainNamespaces.domainNamespaceId, - // Certificate + // Certificate fields - we'll get all valid certs and filter in application logic + certificateId: certificates.certId, + certificateDomain: certificates.domain, + certificateWildcard: certificates.wildcard, certificateStatus: certificates.status }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) - .leftJoin(certificates, eq(certificates.domainId, resources.domainId)) + .leftJoin( + certificates, + and( + eq(certificates.domainId, resources.domainId), + eq(certificates.status, "valid"), + isNotNull(certificates.certFile), + isNotNull(certificates.keyFile) + ) + ) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) @@ -127,6 +138,14 @@ export async function getTraefikConfig( // Group by resource and include targets with their unique site data const resourcesMap = new Map(); + + // Track certificates per resource to determine the correct certificate status + const resourceCertificates = new Map>(); resourcesWithTargetsAndSites.forEach((row) => { const resourceId = row.resourceId; @@ -151,7 +170,25 @@ export async function getTraefikConfig( .filter(Boolean) .join("-"); const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); - const key = sanitize(mapKey); + const key = sanitize(mapKey) || ""; + + // Track certificates for this resource + if (row.certificateId && row.certificateDomain && row.certificateStatus) { + if (!resourceCertificates.has(key)) { + resourceCertificates.set(key, []); + } + + const certList = resourceCertificates.get(key)!; + // Only add if not already present (avoid duplicates from multiple targets) + if (!certList.some(cert => cert.id === row.certificateId)) { + certList.push({ + id: row.certificateId, + domain: row.certificateDomain, + wildcard: row.certificateWildcard, + status: row.certificateStatus + }); + } + } if (!resourcesMap.has(key)) { const validation = validatePathRewriteConfig( @@ -168,6 +205,26 @@ export async function getTraefikConfig( return; } + // Determine the correct certificate status for this resource + let certificateStatus: string | null = null; + const resourceCerts = resourceCertificates.get(key) || []; + + if (row.fullDomain && resourceCerts.length > 0) { + // Find the best matching certificate + // Priority: exact domain match > wildcard match + const exactMatch = resourceCerts.find(cert => + cert.domain === row.fullDomain + ); + + const wildcardMatch = resourceCerts.find(cert => + cert.wildcard && cert.domain && + row.fullDomain!.endsWith(`.${cert.domain}`) + ); + + const matchingCert = exactMatch || wildcardMatch; + certificateStatus = matchingCert?.status || null; + } + resourcesMap.set(key, { resourceId: row.resourceId, name: resourceName, @@ -183,7 +240,7 @@ export async function getTraefikConfig( tlsServerName: row.tlsServerName, setHostHeader: row.setHostHeader, enableProxy: row.enableProxy, - certificateStatus: row.certificateStatus, + certificateStatus: certificateStatus, targets: [], headers: row.headers, path: row.path, // the targets will all have the same path @@ -256,12 +313,12 @@ export async function getTraefikConfig( } // TODO: for now dont filter it out because if you have multiple domain ids and one is failed it causes all of them to fail - // if (resource.certificateStatus !== "valid" && privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { - // logger.debug( - // `Resource ${resource.resourceId} has certificate stats ${resource.certificateStats}` - // ); - // continue; - // } + if (resource.certificateStatus !== "valid" && privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { + logger.debug( + `Resource ${resource.resourceId} has certificate status ${resource.certificateStatus}` + ); + continue; + } // add routers and services empty objects if they don't exist if (!config_output.http.routers) {