diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 6c22b09eef4..ef16e07c87f 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -14,6 +14,12 @@ BETTER_AUTH_URL=http://localhost:3000 # NextJS (Required) NEXT_PUBLIC_APP_URL=http://localhost:3000 # INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL +# NEXT_PUBLIC_ENABLE_LANDING_PAGE=true # Optional public-page flag. Unset = enabled; set false to disable / +# NEXT_PUBLIC_ENABLE_STUDIO_PAGES=true # Optional public-page flag. Unset = enabled; set false to disable /studio* and studio feeds +# NEXT_PUBLIC_ENABLE_CHANGELOG_PAGE=true # Optional public-page flag. Unset = enabled; set false to disable /changelog +# NEXT_PUBLIC_ENABLE_LEGAL_PAGES=true # Optional public-page flag. Unset = enabled; set false to disable /terms and /privacy +# NEXT_PUBLIC_ENABLE_TEMPLATES_PAGES=true # Optional public-page flag. Unset = enabled; set false to disable /templates and /templates/[id] +# NEXT_PUBLIC_ENABLE_CAREERS_LINK=true # Optional public-page flag. Unset = enabled; set false to disable careers links and /careers redirect # Security (Required) ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables diff --git a/apps/sim/app/(auth)/auth-legal-links.test.tsx b/apps/sim/app/(auth)/auth-legal-links.test.tsx new file mode 100644 index 00000000000..ae028667508 --- /dev/null +++ b/apps/sim/app/(auth)/auth-legal-links.test.tsx @@ -0,0 +1,178 @@ +/** + * @vitest-environment node + */ + +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetEnv, mockFeatureFlags } = vi.hoisted(() => ({ + mockGetEnv: vi.fn((key: string) => { + if (key === 'NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED') return 'true' + if (key === 'NEXT_PUBLIC_SSO_ENABLED') return 'false' + return undefined + }), + mockFeatureFlags: { + getAuthTermsLinkConfig: (() => ({ href: '/terms', isExternal: false })) as () => { + href: string + isExternal: boolean + } | null, + getAuthPrivacyLinkConfig: (() => ({ href: '/privacy', isExternal: false })) as () => { + href: string + isExternal: boolean + } | null, + }, +})) + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: React.ComponentProps<'a'>) => ( + + {children} + + ), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})) + +vi.mock('next/font/google', () => ({ + Inter: () => ({ className: 'font-inter', variable: '--font-inter' }), +})) + +vi.mock('next/font/local', () => ({ + default: () => ({ className: 'font-soehne', variable: '--font-soehne' }), +})) + +vi.mock('@/lib/core/config/env', () => ({ + getEnv: mockGetEnv, + isTruthy: (value: string | undefined) => value === 'true', + isFalsy: (value: string | undefined) => value === 'false', + env: { + NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: 'true', + }, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + getAuthTermsLinkConfig: () => mockFeatureFlags.getAuthTermsLinkConfig(), + getAuthPrivacyLinkConfig: () => mockFeatureFlags.getAuthPrivacyLinkConfig(), +})) + +vi.mock('@/lib/auth/auth-client', () => ({ + client: { + signIn: { email: vi.fn(), social: vi.fn() }, + signUp: { email: vi.fn() }, + forgetPassword: vi.fn(), + }, + useSession: () => ({ refetch: vi.fn() }), +})) + +vi.mock('@/hooks/use-branded-button-class', () => ({ + useBrandedButtonClass: () => 'brand-button', +})) + +vi.mock('@/app/(auth)/components/branded-button', () => ({ + BrandedButton: ({ children }: { children: React.ReactNode }) => , +})) + +vi.mock('@/app/(auth)/components/social-login-buttons', () => ({ + SocialLoginButtons: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/(auth)/components/sso-login-button', () => ({ + SSOLoginButton: () => , +})) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/components/ui/input', () => ({ + Input: (props: React.ComponentProps<'input'>) => , +})) + +vi.mock('@/components/ui/label', () => ({ + Label: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children }: { children: React.ReactNode }) => , +})) + +vi.mock('@/lib/core/utils/cn', () => ({ + cn: (...values: Array) => values.filter(Boolean).join(' '), +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://example.com', +})) + +vi.mock('@/lib/messaging/email/validation', () => ({ + quickValidateEmail: () => ({ isValid: true }), +})) + +import SSOForm from '../../ee/sso/components/sso-form' +import LoginPage from './login/login-form' +import SignupPage from './signup/signup-form' + +describe('auth legal link rendering', () => { + beforeEach(() => { + mockFeatureFlags.getAuthTermsLinkConfig = () => ({ href: '/terms', isExternal: false }) + mockFeatureFlags.getAuthPrivacyLinkConfig = () => ({ href: '/privacy', isExternal: false }) + }) + + it('renders internal legal links on auth surfaces when legal pages are enabled', () => { + const loginHtml = renderToStaticMarkup( + + ) + const signupHtml = renderToStaticMarkup( + + ) + const ssoHtml = renderToStaticMarkup() + + expect(loginHtml).toContain('href="/terms"') + expect(loginHtml).toContain('href="/privacy"') + expect(signupHtml).toContain('href="/terms"') + expect(signupHtml).toContain('href="/privacy"') + expect(ssoHtml).toContain('href="/terms"') + expect(ssoHtml).toContain('href="/privacy"') + }) + + it('renders external legal links on auth surfaces when legal pages are disabled but external urls exist', () => { + mockFeatureFlags.getAuthTermsLinkConfig = () => ({ + href: 'https://legal.example.com/terms', + isExternal: true, + }) + mockFeatureFlags.getAuthPrivacyLinkConfig = () => ({ + href: 'https://legal.example.com/privacy', + isExternal: true, + }) + + const loginHtml = renderToStaticMarkup( + + ) + + expect(loginHtml).toContain('href="https://legal.example.com/terms"') + expect(loginHtml).toContain('href="https://legal.example.com/privacy"') + }) + + it('hides only the missing individual legal link when no external fallback exists', () => { + mockFeatureFlags.getAuthTermsLinkConfig = () => null + mockFeatureFlags.getAuthPrivacyLinkConfig = () => ({ + href: 'https://legal.example.com/privacy', + isExternal: true, + }) + + const loginHtml = renderToStaticMarkup( + + ) + + expect(loginHtml).not.toContain('Terms of Service') + expect(loginHtml).toContain('Privacy Policy') + expect(loginHtml).toContain('href="https://legal.example.com/privacy"') + }) +}) diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index c58b102bc54..3e621008ba5 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -16,6 +16,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { client } from '@/lib/auth/auth-client' import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env' +import { getAuthPrivacyLinkConfig, getAuthTermsLinkConfig } from '@/lib/core/config/feature-flags' import { cn } from '@/lib/core/utils/cn' import { getBaseUrl } from '@/lib/core/utils/urls' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -114,6 +115,8 @@ export default function LoginPage({ const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false) const [forgotPasswordEmail, setForgotPasswordEmail] = useState('') const [isSubmittingReset, setIsSubmittingReset] = useState(false) + const termsLinkConfig = getAuthTermsLinkConfig() + const privacyLinkConfig = getAuthPrivacyLinkConfig() const [resetStatus, setResetStatus] = useState<{ type: 'success' | 'error' | null message: string @@ -548,28 +551,34 @@ export default function LoginPage({ )} -
- By signing in, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - -
+ By signing in, you agree to our{' '} + {termsLinkConfig ? ( + + Terms of Service + + ) : null} + {termsLinkConfig && privacyLinkConfig ? ' and ' : null} + {privacyLinkConfig ? ( + + Privacy Policy + + ) : null} + + )} diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index bab806f23a2..6b33dbc55ab 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { client, useSession } from '@/lib/auth/auth-client' import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env' +import { getAuthPrivacyLinkConfig, getAuthTermsLinkConfig } from '@/lib/core/config/feature-flags' import { cn } from '@/lib/core/utils/cn' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { inter } from '@/app/_styles/fonts/inter/inter' @@ -97,6 +98,8 @@ function SignupFormContent({ const [redirectUrl, setRedirectUrl] = useState('') const [isInviteFlow, setIsInviteFlow] = useState(false) const buttonClass = useBrandedButtonClass() + const termsLinkConfig = getAuthTermsLinkConfig() + const privacyLinkConfig = getAuthPrivacyLinkConfig() const [name, setName] = useState('') const [nameErrors, setNameErrors] = useState([]) @@ -547,28 +550,34 @@ function SignupFormContent({ -
- By creating an account, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - -
+ By creating an account, you agree to our{' '} + {termsLinkConfig ? ( + + Terms of Service + + ) : null} + {termsLinkConfig && privacyLinkConfig ? ' and ' : null} + {privacyLinkConfig ? ( + + Privacy Policy + + ) : null} + + )} ) } diff --git a/apps/sim/app/(home)/components/navbar/navbar.test.tsx b/apps/sim/app/(home)/components/navbar/navbar.test.tsx new file mode 100644 index 00000000000..169e3531c85 --- /dev/null +++ b/apps/sim/app/(home)/components/navbar/navbar.test.tsx @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ + +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFeatureFlags } = vi.hoisted(() => ({ + mockFeatureFlags: { + isPublicCareersLinkEnabled: true, + }, +})) + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: React.ComponentProps<'a'>) => ( + + {children} + + ), +})) + +vi.mock('next/image', () => ({ + default: (props: React.ComponentProps<'img'>) => {props.alt, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + get isPublicCareersLinkEnabled() { + return mockFeatureFlags.isPublicCareersLinkEnabled + }, +})) + +vi.mock('@/app/(home)/components/navbar/components/github-stars', () => ({ + GitHubStars: () =>
GitHubStars
, +})) + +import Navbar from './navbar' + +describe('home navbar careers link', () => { + beforeEach(() => { + mockFeatureFlags.isPublicCareersLinkEnabled = true + }) + + it('hides careers when the careers link flag is disabled', () => { + mockFeatureFlags.isPublicCareersLinkEnabled = false + + const html = renderToStaticMarkup() + + expect(html).not.toContain('>Careers<') + expect(html).toContain('>Docs<') + expect(html).toContain('>Pricing<') + expect(html).toContain('>Enterprise<') + }) + + it('keeps careers in the original position when the flag is enabled', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('>Pricing<') + expect(html).toContain('>Careers<') + expect(html).toContain('>Enterprise<') + expect(html.indexOf('>Pricing<')).toBeLessThan(html.indexOf('>Careers<')) + expect(html.indexOf('>Careers<')).toBeLessThan(html.indexOf('>Enterprise<')) + }) +}) diff --git a/apps/sim/app/(home)/components/navbar/navbar.tsx b/apps/sim/app/(home)/components/navbar/navbar.tsx index c889eef7246..80534bdcf91 100644 --- a/apps/sim/app/(home)/components/navbar/navbar.tsx +++ b/apps/sim/app/(home)/components/navbar/navbar.tsx @@ -1,6 +1,7 @@ import Image from 'next/image' import Link from 'next/link' import { ChevronDown } from '@/components/emcn' +import { isPublicCareersLinkEnabled } from '@/lib/core/config/feature-flags' import { GitHubStars } from '@/app/(home)/components/navbar/components/github-stars' interface NavLink { @@ -10,12 +11,14 @@ interface NavLink { icon?: 'chevron' } -const NAV_LINKS: NavLink[] = [ - { label: 'Docs', href: 'https://docs.sim.ai', external: true }, - { label: 'Pricing', href: '/pricing' }, - { label: 'Careers', href: '/careers' }, - { label: 'Enterprise', href: '/enterprise' }, -] +function getNavLinks(): NavLink[] { + return [ + { label: 'Docs', href: 'https://docs.sim.ai', external: true }, + { label: 'Pricing', href: '/pricing' }, + ...(isPublicCareersLinkEnabled ? [{ label: 'Careers', href: '/careers' }] : []), + { label: 'Enterprise', href: '/enterprise' }, + ] +} /** Logo and nav edge: horizontal padding (px) for left/right symmetry. */ const LOGO_CELL = 'flex items-center px-[20px]' @@ -24,6 +27,8 @@ const LOGO_CELL = 'flex items-center px-[20px]' const LINK_CELL = 'flex items-center px-[14px]' export default function Navbar() { + const navLinks = getNavLinks() + return (