Skip to content

Commit c65dfae

Browse files
author
test
committed
feat(public): gate public pages with explicit flags
1 parent 9229002 commit c65dfae

File tree

35 files changed

+1771
-254
lines changed

35 files changed

+1771
-254
lines changed

apps/sim/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ BETTER_AUTH_URL=http://localhost:3000
1414
# NextJS (Required)
1515
NEXT_PUBLIC_APP_URL=http://localhost:3000
1616
# 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
17+
# NEXT_PUBLIC_ENABLE_LANDING_PAGE=true # Optional public-page flag. Unset = enabled; set false to disable /
18+
# NEXT_PUBLIC_ENABLE_STUDIO_PAGES=true # Optional public-page flag. Unset = enabled; set false to disable /studio* and studio feeds
19+
# NEXT_PUBLIC_ENABLE_CHANGELOG_PAGE=true # Optional public-page flag. Unset = enabled; set false to disable /changelog
20+
# NEXT_PUBLIC_ENABLE_LEGAL_PAGES=true # Optional public-page flag. Unset = enabled; set false to disable /terms and /privacy
21+
# NEXT_PUBLIC_ENABLE_TEMPLATES_PAGES=true # Optional public-page flag. Unset = enabled; set false to disable /templates and /templates/[id]
22+
# NEXT_PUBLIC_ENABLE_CAREERS_LINK=true # Optional public-page flag. Unset = enabled; set false to disable careers links and /careers redirect
1723

1824
# Security (Required)
1925
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { renderToStaticMarkup } from 'react-dom/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockGetEnv, mockFeatureFlags } = vi.hoisted(() => ({
9+
mockGetEnv: vi.fn((key: string) => {
10+
if (key === 'NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED') return 'true'
11+
if (key === 'NEXT_PUBLIC_SSO_ENABLED') return 'false'
12+
return undefined
13+
}),
14+
mockFeatureFlags: {
15+
getAuthTermsLinkConfig: (() => ({ href: '/terms', isExternal: false })) as () => {
16+
href: string
17+
isExternal: boolean
18+
} | null,
19+
getAuthPrivacyLinkConfig: (() => ({ href: '/privacy', isExternal: false })) as () => {
20+
href: string
21+
isExternal: boolean
22+
} | null,
23+
},
24+
}))
25+
26+
vi.mock('next/link', () => ({
27+
default: ({ href, children, ...props }: React.ComponentProps<'a'>) => (
28+
<a href={typeof href === 'string' ? href : ''} {...props}>
29+
{children}
30+
</a>
31+
),
32+
}))
33+
34+
vi.mock('next/navigation', () => ({
35+
useRouter: () => ({ push: vi.fn() }),
36+
useSearchParams: () => new URLSearchParams(),
37+
}))
38+
39+
vi.mock('next/font/google', () => ({
40+
Inter: () => ({ className: 'font-inter', variable: '--font-inter' }),
41+
}))
42+
43+
vi.mock('next/font/local', () => ({
44+
default: () => ({ className: 'font-soehne', variable: '--font-soehne' }),
45+
}))
46+
47+
vi.mock('@/lib/core/config/env', () => ({
48+
getEnv: mockGetEnv,
49+
isTruthy: (value: string | undefined) => value === 'true',
50+
isFalsy: (value: string | undefined) => value === 'false',
51+
env: {
52+
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: 'true',
53+
},
54+
}))
55+
56+
vi.mock('@/lib/core/config/feature-flags', () => ({
57+
getAuthTermsLinkConfig: () => mockFeatureFlags.getAuthTermsLinkConfig(),
58+
getAuthPrivacyLinkConfig: () => mockFeatureFlags.getAuthPrivacyLinkConfig(),
59+
}))
60+
61+
vi.mock('@/lib/auth/auth-client', () => ({
62+
client: {
63+
signIn: { email: vi.fn(), social: vi.fn() },
64+
signUp: { email: vi.fn() },
65+
forgetPassword: vi.fn(),
66+
},
67+
useSession: () => ({ refetch: vi.fn() }),
68+
}))
69+
70+
vi.mock('@/hooks/use-branded-button-class', () => ({
71+
useBrandedButtonClass: () => 'brand-button',
72+
}))
73+
74+
vi.mock('@/app/(auth)/components/branded-button', () => ({
75+
BrandedButton: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
76+
}))
77+
78+
vi.mock('@/app/(auth)/components/social-login-buttons', () => ({
79+
SocialLoginButtons: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
80+
}))
81+
82+
vi.mock('@/app/(auth)/components/sso-login-button', () => ({
83+
SSOLoginButton: () => <button>SSO</button>,
84+
}))
85+
86+
vi.mock('@/components/ui/dialog', () => ({
87+
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
88+
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
89+
DialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
90+
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
91+
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
92+
}))
93+
94+
vi.mock('@/components/ui/input', () => ({
95+
Input: (props: React.ComponentProps<'input'>) => <input {...props} />,
96+
}))
97+
98+
vi.mock('@/components/ui/label', () => ({
99+
Label: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
100+
}))
101+
102+
vi.mock('@/components/ui/button', () => ({
103+
Button: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
104+
}))
105+
106+
vi.mock('@/lib/core/utils/cn', () => ({
107+
cn: (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' '),
108+
}))
109+
110+
vi.mock('@/lib/core/utils/urls', () => ({
111+
getBaseUrl: () => 'https://example.com',
112+
}))
113+
114+
vi.mock('@/lib/messaging/email/validation', () => ({
115+
quickValidateEmail: () => ({ isValid: true }),
116+
}))
117+
118+
import SSOForm from '../../ee/sso/components/sso-form'
119+
import LoginPage from './login/login-form'
120+
import SignupPage from './signup/signup-form'
121+
122+
describe('auth legal link rendering', () => {
123+
beforeEach(() => {
124+
mockFeatureFlags.getAuthTermsLinkConfig = () => ({ href: '/terms', isExternal: false })
125+
mockFeatureFlags.getAuthPrivacyLinkConfig = () => ({ href: '/privacy', isExternal: false })
126+
})
127+
128+
it('renders internal legal links on auth surfaces when legal pages are enabled', () => {
129+
const loginHtml = renderToStaticMarkup(
130+
<LoginPage githubAvailable={false} googleAvailable={false} isProduction={false} />
131+
)
132+
const signupHtml = renderToStaticMarkup(
133+
<SignupPage githubAvailable={false} googleAvailable={false} isProduction={false} />
134+
)
135+
const ssoHtml = renderToStaticMarkup(<SSOForm />)
136+
137+
expect(loginHtml).toContain('href="/terms"')
138+
expect(loginHtml).toContain('href="/privacy"')
139+
expect(signupHtml).toContain('href="/terms"')
140+
expect(signupHtml).toContain('href="/privacy"')
141+
expect(ssoHtml).toContain('href="/terms"')
142+
expect(ssoHtml).toContain('href="/privacy"')
143+
})
144+
145+
it('renders external legal links on auth surfaces when legal pages are disabled but external urls exist', () => {
146+
mockFeatureFlags.getAuthTermsLinkConfig = () => ({
147+
href: 'https://legal.example.com/terms',
148+
isExternal: true,
149+
})
150+
mockFeatureFlags.getAuthPrivacyLinkConfig = () => ({
151+
href: 'https://legal.example.com/privacy',
152+
isExternal: true,
153+
})
154+
155+
const loginHtml = renderToStaticMarkup(
156+
<LoginPage githubAvailable={false} googleAvailable={false} isProduction={false} />
157+
)
158+
159+
expect(loginHtml).toContain('href="https://legal.example.com/terms"')
160+
expect(loginHtml).toContain('href="https://legal.example.com/privacy"')
161+
})
162+
163+
it('hides only the missing individual legal link when no external fallback exists', () => {
164+
mockFeatureFlags.getAuthTermsLinkConfig = () => null
165+
mockFeatureFlags.getAuthPrivacyLinkConfig = () => ({
166+
href: 'https://legal.example.com/privacy',
167+
isExternal: true,
168+
})
169+
170+
const loginHtml = renderToStaticMarkup(
171+
<LoginPage githubAvailable={false} googleAvailable={false} isProduction={false} />
172+
)
173+
174+
expect(loginHtml).not.toContain('Terms of Service</a>')
175+
expect(loginHtml).toContain('Privacy Policy</a>')
176+
expect(loginHtml).toContain('href="https://legal.example.com/privacy"')
177+
})
178+
})

apps/sim/app/(auth)/login/login-form.tsx

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Input } from '@/components/ui/input'
1616
import { Label } from '@/components/ui/label'
1717
import { client } from '@/lib/auth/auth-client'
1818
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
19+
import { getAuthPrivacyLinkConfig, getAuthTermsLinkConfig } from '@/lib/core/config/feature-flags'
1920
import { cn } from '@/lib/core/utils/cn'
2021
import { getBaseUrl } from '@/lib/core/utils/urls'
2122
import { quickValidateEmail } from '@/lib/messaging/email/validation'
@@ -114,6 +115,8 @@ export default function LoginPage({
114115
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
115116
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
116117
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
118+
const termsLinkConfig = getAuthTermsLinkConfig()
119+
const privacyLinkConfig = getAuthPrivacyLinkConfig()
117120
const [resetStatus, setResetStatus] = useState<{
118121
type: 'success' | 'error' | null
119122
message: string
@@ -548,28 +551,34 @@ export default function LoginPage({
548551
</div>
549552
)}
550553

551-
<div
552-
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
553-
>
554-
By signing in, you agree to our{' '}
555-
<Link
556-
href='/terms'
557-
target='_blank'
558-
rel='noopener noreferrer'
559-
className='auth-link underline-offset-4 transition hover:underline'
554+
{(termsLinkConfig || privacyLinkConfig) && (
555+
<div
556+
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
560557
>
561-
Terms of Service
562-
</Link>{' '}
563-
and{' '}
564-
<Link
565-
href='/privacy'
566-
target='_blank'
567-
rel='noopener noreferrer'
568-
className='auth-link underline-offset-4 transition hover:underline'
569-
>
570-
Privacy Policy
571-
</Link>
572-
</div>
558+
By signing in, you agree to our{' '}
559+
{termsLinkConfig ? (
560+
<Link
561+
href={termsLinkConfig.href}
562+
target='_blank'
563+
rel='noopener noreferrer'
564+
className='auth-link underline-offset-4 transition hover:underline'
565+
>
566+
Terms of Service
567+
</Link>
568+
) : null}
569+
{termsLinkConfig && privacyLinkConfig ? ' and ' : null}
570+
{privacyLinkConfig ? (
571+
<Link
572+
href={privacyLinkConfig.href}
573+
target='_blank'
574+
rel='noopener noreferrer'
575+
className='auth-link underline-offset-4 transition hover:underline'
576+
>
577+
Privacy Policy
578+
</Link>
579+
) : null}
580+
</div>
581+
)}
573582

574583
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
575584
<DialogContent className='auth-card auth-card-shadow max-w-[540px] rounded-[10px] border backdrop-blur-sm'>

apps/sim/app/(auth)/signup/signup-form.tsx

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'
99
import { Label } from '@/components/ui/label'
1010
import { client, useSession } from '@/lib/auth/auth-client'
1111
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
12+
import { getAuthPrivacyLinkConfig, getAuthTermsLinkConfig } from '@/lib/core/config/feature-flags'
1213
import { cn } from '@/lib/core/utils/cn'
1314
import { quickValidateEmail } from '@/lib/messaging/email/validation'
1415
import { inter } from '@/app/_styles/fonts/inter/inter'
@@ -97,6 +98,8 @@ function SignupFormContent({
9798
const [redirectUrl, setRedirectUrl] = useState('')
9899
const [isInviteFlow, setIsInviteFlow] = useState(false)
99100
const buttonClass = useBrandedButtonClass()
101+
const termsLinkConfig = getAuthTermsLinkConfig()
102+
const privacyLinkConfig = getAuthPrivacyLinkConfig()
100103

101104
const [name, setName] = useState('')
102105
const [nameErrors, setNameErrors] = useState<string[]>([])
@@ -547,28 +550,34 @@ function SignupFormContent({
547550
</Link>
548551
</div>
549552

550-
<div
551-
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
552-
>
553-
By creating an account, you agree to our{' '}
554-
<Link
555-
href='/terms'
556-
target='_blank'
557-
rel='noopener noreferrer'
558-
className='auth-link underline-offset-4 transition hover:underline'
559-
>
560-
Terms of Service
561-
</Link>{' '}
562-
and{' '}
563-
<Link
564-
href='/privacy'
565-
target='_blank'
566-
rel='noopener noreferrer'
567-
className='auth-link underline-offset-4 transition hover:underline'
553+
{(termsLinkConfig || privacyLinkConfig) && (
554+
<div
555+
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
568556
>
569-
Privacy Policy
570-
</Link>
571-
</div>
557+
By creating an account, you agree to our{' '}
558+
{termsLinkConfig ? (
559+
<Link
560+
href={termsLinkConfig.href}
561+
target='_blank'
562+
rel='noopener noreferrer'
563+
className='auth-link underline-offset-4 transition hover:underline'
564+
>
565+
Terms of Service
566+
</Link>
567+
) : null}
568+
{termsLinkConfig && privacyLinkConfig ? ' and ' : null}
569+
{privacyLinkConfig ? (
570+
<Link
571+
href={privacyLinkConfig.href}
572+
target='_blank'
573+
rel='noopener noreferrer'
574+
className='auth-link underline-offset-4 transition hover:underline'
575+
>
576+
Privacy Policy
577+
</Link>
578+
) : null}
579+
</div>
580+
)}
572581
</>
573582
)
574583
}
Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
import Image from 'next/image'
22
import Link from 'next/link'
3+
import { isPublicLandingPageEnabled } from '@/lib/core/config/feature-flags'
34

45
export default function Logo() {
6+
const logoImage = (
7+
<Image
8+
src='/logo/b&w/text/b&w.svg'
9+
alt='Sim - Workflows for LLMs'
10+
width={49.78314}
11+
height={24.276}
12+
priority
13+
quality={90}
14+
/>
15+
)
16+
17+
if (!isPublicLandingPageEnabled) {
18+
return <div aria-label='Sim home'>{logoImage}</div>
19+
}
20+
521
return (
622
<Link href='/' aria-label='Sim home'>
7-
<Image
8-
src='/logo/b&w/text/b&w.svg'
9-
alt='Sim - Workflows for LLMs'
10-
width={49.78314}
11-
height={24.276}
12-
priority
13-
quality={90}
14-
/>
23+
{logoImage}
1524
</Link>
1625
)
1726
}

0 commit comments

Comments
 (0)