diff --git a/apps/app/src/app/(app)/setup/components/OnboardingStepInput.test.ts b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.test.ts new file mode 100644 index 0000000000..36b797910a --- /dev/null +++ b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { cleanDomainInput, isValidDomain } from './OnboardingStepInput'; + +describe('cleanDomainInput', () => { + it('strips a trailing slash', () => { + expect(cleanDomainInput('example.com/')).toBe('example.com'); + }); + + it('strips multiple trailing slashes', () => { + expect(cleanDomainInput('example.com//')).toBe('example.com'); + }); + + it('strips the https:// protocol', () => { + expect(cleanDomainInput('https://example.com')).toBe('example.com'); + }); + + it('strips the http:// protocol', () => { + expect(cleanDomainInput('http://example.com')).toBe('example.com'); + }); + + it('strips a www. prefix', () => { + expect(cleanDomainInput('www.example.com')).toBe('example.com'); + }); + + it('strips protocol, www, and a trailing slash together', () => { + expect(cleanDomainInput('https://www.example.com/')).toBe('example.com'); + }); + + it('trims surrounding whitespace', () => { + expect(cleanDomainInput(' example.com ')).toBe('example.com'); + }); + + it('leaves an already-clean domain unchanged', () => { + expect(cleanDomainInput('example.com')).toBe('example.com'); + }); +}); + +describe('isValidDomain', () => { + it('accepts an empty string (optional field)', () => { + expect(isValidDomain('')).toBe(true); + }); + + it('accepts a plain domain', () => { + expect(isValidDomain('example.com')).toBe(true); + }); + + it('accepts a domain with a trailing slash', () => { + expect(isValidDomain('example.com/')).toBe(true); + }); + + it('accepts a domain with protocol, www, and a trailing slash', () => { + expect(isValidDomain('https://www.example.com/')).toBe(true); + }); + + it('accepts a subdomain', () => { + expect(isValidDomain('sub.example.com')).toBe(true); + }); + + it('rejects a value with no dot', () => { + expect(isValidDomain('example')).toBe(false); + }); + + it('rejects a value with a path', () => { + expect(isValidDomain('example.com/about')).toBe(false); + }); + + it('rejects a value with spaces', () => { + expect(isValidDomain('exa mple.com')).toBe(false); + }); +}); diff --git a/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx index 2a3febbb17..004e435e28 100644 --- a/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx +++ b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx @@ -533,12 +533,25 @@ const normalizeVendorName = (name: string): string => { .trim(); }; +// Helper to strip protocol, www, and trailing slashes from a website URL, +// leaving just the bare domain for display/validation +export const cleanDomainInput = (raw: string): string => { + return raw + .trim() + .replace(/^(https?:\/\/)+/gi, '') + .replace(/^(www\.)+/gi, '') + .trim() + .replace(/\/+$/, ''); +}; + // Helper to validate domain/URL format -const isValidDomain = (domain: string): boolean => { +export const isValidDomain = (domain: string): boolean => { if (!domain || domain.trim() === '') return true; // Empty is valid (optional field) - // Clean the input - const cleaned = domain.trim().toLowerCase(); + // Clean the input (also strips a trailing slash defensively, in case the + // value wasn't run through cleanDomainInput first) + const cleaned = cleanDomainInput(domain).toLowerCase(); + if (!cleaned) return true; // Domain regex: allows subdomains, requires at least one dot and valid TLD const domainRegex = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/; @@ -593,7 +606,7 @@ function SoftwareVendorInput({ if (onTouchedInvalidUrlChange) { const hasTouchedInvalid = customVendorsForCallback.some((vendor) => { if (!touchedUrls.has(vendor.name)) return false; - const url = (vendor.website || '').replace(/^https?:\/\//, '').replace(/^www\./, ''); + const url = cleanDomainInput(vendor.website || ''); return url.length > 0 && !isValidDomain(url); }); onTouchedInvalidUrlChange(hasTouchedInvalid); @@ -924,9 +937,7 @@ function SoftwareVendorInput({
{customVendors.map((vendor) => { // Strip protocol for display, we'll add it back on save - const displayValue = (vendor.website || '') - .replace(/^https?:\/\//, '') - .replace(/^www\./, ''); + const displayValue = cleanDomainInput(vendor.website || ''); const isTouched = touchedUrls.has(vendor.name); const isValid = isValidDomain(displayValue); @@ -953,11 +964,8 @@ function SoftwareVendorInput({ type="text" value={displayValue} onChange={(e) => { - // Clean input: remove any protocol, www, and trim - let value = e.target.value - .replace(/^(https?:\/\/)+/gi, '') // Remove one or more https:// - .replace(/^(www\.)+/gi, '') // Remove one or more www. - .trim(); + // Clean input: remove any protocol, www, trailing slashes, and trim + const value = cleanDomainInput(e.target.value); const fullUrl = value ? `https://${value}` : ''; handleCustomVendorWebsiteChange(vendor.name, fullUrl); @@ -983,9 +991,9 @@ function SoftwareVendorInput({ // Get current value from form state const currentVendors = (form.watch('customVendors') as CustomVendor[] | undefined) || []; const currentVendor = currentVendors.find((v) => v.name === vendor.name); - const currentValue = (currentVendor?.website || '') - .replace(/^https?:\/\//, '') - .replace(/^www\./, ''); + const currentValue = cleanDomainInput( + currentVendor?.website || '', + ); const isValid = isValidDomain(currentValue); // Only mark as touched if invalid (to show error) if (!isValid && currentValue.length > 0) {