diff --git a/CHANGELOG.md b/CHANGELOG.md index 0941c0169..5224edaf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Public signup controls now reflect `ALLOW_REGISTRATION`: closed registration shows a localized invite-only message and Login action, while invitation links retain access to the registration form. The existing flag is exposed through `/api/config`. + ## [1.9.15] - 2026-09-22 ### Changed diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index d95661d50..0f6c4c86a 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -43,6 +43,7 @@ async def get_config( } return { + "allow_registration": settings.ALLOW_REGISTRATION, "stripe_enabled": settings.STRIPE_ENABLED, "stripe_trial_days": settings.STRIPE_TRIAL_DAYS, "freemium_trial_enabled": settings.FREEMIUM_TRIAL_ENABLED, diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 89035dd80..911541ad7 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -691,3 +691,64 @@ async def test_register_sets_freemium_trial(client): body = me.json() assert body["freemium_trial_used"] is True assert body["freemium_trial_ends_at"] is not None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("allow_registration", [True, False]) +async def test_config_exposes_registration_setting(client, allow_registration): + from app.core.config import settings + + with patch.object(settings, "ALLOW_REGISTRATION", allow_registration): + response = await client.get("/api/config") + + assert response.status_code == 200 + assert response.json()["allow_registration"] is allow_registration + + +@pytest.mark.asyncio +async def test_register_when_closed_with_single_use_invite(client, admin_user): + from app.core.config import settings + + _, headers = admin_user + invite_response = await client.post("/api/admin/invite", headers=headers) + assert invite_response.status_code == 200 + token = invite_response.json()["invite_url"].split("invite=")[1] + account = { + "username": "invited", + "email": "invited@test.com", + "password": "Test1234!@", + "native_language": "en", + "invite_token": token, + } + with patch.object(settings, "ALLOW_REGISTRATION", False): + response = await client.post("/api/auth/register", json=account) + assert response.status_code == 200 + assert "access_token" in response.json() + assert "refresh_token" in response.cookies + + # A second account cannot reuse the consumed invitation. + response = await client.post( + "/api/auth/register", + json={**account, "username": "another", "email": "another@test.com"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == "Invalid or expired invite" + + +@pytest.mark.asyncio +async def test_register_when_closed_with_invalid_invite(client): + from app.core.config import settings + + with patch.object(settings, "ALLOW_REGISTRATION", False): + response = await client.post( + "/api/auth/register", + json={ + "username": "uninvited", + "email": "uninvited@test.com", + "password": "Test1234!@", + "native_language": "en", + "invite_token": "unknown-token", + }, + ) + assert response.status_code == 403 + assert response.json()["detail"] == "Invalid or expired invite" diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index d4c23a869..b092a14c2 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { Suspense, useCallback, useState } from 'react' +import { Suspense, useCallback, useEffect, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import Link from 'next/link' import Image from 'next/image' @@ -9,6 +9,7 @@ import { Loader2 } from 'lucide-react' import { apiFetch } from '@/lib/api' import { mapUser } from '@/lib/mappers' import { useAuthStore } from '@/store/auth' +import { useConfigStore } from '@/store/config' function LoginForm() { const t = useTranslations('auth.login') @@ -16,6 +17,13 @@ function LoginForm() { const router = useRouter() const searchParams = useSearchParams() const registered = searchParams.get('registered') === 'true' + const allowRegistration = useConfigStore((s) => s.allowRegistration) + const loadConfig = useConfigStore((s) => s.load) + + useEffect(() => { + void loadConfig() + }, [loadConfig]) + const setTokens = useAuthStore((s) => s.setTokens) const setUser = useAuthStore((s) => s.setUser) const [email, setEmail] = useState('') @@ -201,15 +209,17 @@ function LoginForm() { -

- {t('noAccount')}{' '} - - {t('register')} - -

+ {allowRegistration && ( +

+ {t('noAccount')}{' '} + + {t('register')} + +

+ )}

{t('termsAccept')}{' '} {t('termsLink')} {' '} {t('andWord')}{' '} {t('privacyLink')} @@ -423,10 +433,66 @@ function RegisterForm() { ) } +function RegistrationGate() { + const t = useTranslations('auth.register') + const tCommon = useTranslations('common') + const invite = useSearchParams().get('invite') + const allowRegistration = useConfigStore((s) => s.allowRegistration) + const loadConfig = useConfigStore((s) => s.load) + const [configLoading, setConfigLoading] = useState(true) + + useEffect(() => { + void loadConfig().finally(() => setConfigLoading(false)) + }, [loadConfig]) + + // Token validity is checked only by the backend when the form is submitted. + if (invite || allowRegistration) return + if (configLoading) return + + return ( +

+
+
+ FreeLingo +

+ FreeLingo +

+

+ {tCommon('tagline')} +

+
+
+
+ ● + + {t('title')} + +
+

+ {t('registrationClosed')} +

+ + {t('login')} + +
+
+
+ ) +} + export default function RegisterPage() { return ( - + ) } diff --git a/frontend/src/app/(legal)/privacy/page.tsx b/frontend/src/app/(legal)/privacy/page.tsx index f7ee4a91b..b050a32b7 100644 --- a/frontend/src/app/(legal)/privacy/page.tsx +++ b/frontend/src/app/(legal)/privacy/page.tsx @@ -1,34 +1,51 @@ 'use client' +import { useEffect } from 'react' import Link from 'next/link' import Image from 'next/image' import { useTranslations } from 'next-intl' import { useSearchParams } from 'next/navigation' +import { useConfigStore } from '@/store/config' export default function PrivacyPage() { const t = useTranslations('legal.privacy') const tCommon = useTranslations('common') const searchParams = useSearchParams() + const allowRegistration = useConfigStore((s) => s.allowRegistration) + const loadConfig = useConfigStore((s) => s.load) + const tRegister = useTranslations('auth.register') + const invite = searchParams.get('invite') + const inviteQuery = invite ? `&invite=${encodeURIComponent(invite)}` : '' const from = searchParams.get('from') const isFromSettings = from === 'settings' const isFromRegister = from === 'register' const isFromLanding = from === 'landing' + + useEffect(() => { + if (isFromRegister) void loadConfig() + }, [isFromRegister, loadConfig]) const backHref = isFromSettings ? '/settings' : isFromRegister - ? '/register' + ? invite + ? `/register?invite=${encodeURIComponent(invite)}` + : allowRegistration + ? '/register' + : '/login' : isFromLanding ? '/' : '/' const backLabel = isFromSettings ? t('linkBackSettings') : isFromRegister - ? t('linkBack') + ? allowRegistration || invite + ? t('linkBack') + : tRegister('login') : tCommon('back') const termsHref = isFromSettings ? '/terms?from=settings' : isFromRegister - ? '/terms?from=register' + ? `/terms?from=register${inviteQuery}` : isFromLanding ? '/terms?from=landing' : '/terms' diff --git a/frontend/src/app/(legal)/terms/page.tsx b/frontend/src/app/(legal)/terms/page.tsx index 22a391237..10e4fd647 100644 --- a/frontend/src/app/(legal)/terms/page.tsx +++ b/frontend/src/app/(legal)/terms/page.tsx @@ -1,34 +1,51 @@ 'use client' +import { useEffect } from 'react' import Link from 'next/link' import Image from 'next/image' import { useTranslations } from 'next-intl' import { useSearchParams } from 'next/navigation' +import { useConfigStore } from '@/store/config' export default function TermsPage() { const t = useTranslations('legal.terms') const tCommon = useTranslations('common') const searchParams = useSearchParams() + const allowRegistration = useConfigStore((s) => s.allowRegistration) + const loadConfig = useConfigStore((s) => s.load) + const tRegister = useTranslations('auth.register') + const invite = searchParams.get('invite') + const inviteQuery = invite ? `&invite=${encodeURIComponent(invite)}` : '' const from = searchParams.get('from') const isFromSettings = from === 'settings' const isFromRegister = from === 'register' const isFromLanding = from === 'landing' + + useEffect(() => { + if (isFromRegister) void loadConfig() + }, [isFromRegister, loadConfig]) const backHref = isFromSettings ? '/settings' : isFromRegister - ? '/register' + ? invite + ? `/register?invite=${encodeURIComponent(invite)}` + : allowRegistration + ? '/register' + : '/login' : isFromLanding ? '/' : '/' const backLabel = isFromSettings ? t('linkBackSettings') : isFromRegister - ? t('linkBack') + ? allowRegistration || invite + ? t('linkBack') + : tRegister('login') : tCommon('back') const privacyHref = isFromSettings ? '/privacy?from=settings' : isFromRegister - ? '/privacy?from=register' + ? `/privacy?from=register${inviteQuery}` : isFromLanding ? '/privacy?from=landing' : '/privacy' diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index f7fafb8d3..2d6c69153 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -77,6 +77,7 @@ export default async function Home() { const tCommon = await getTranslations('common') const tBilling = await getTranslations('billing') + let allowRegistration = false let stripeEnabled = false let trialDays = 7 let priceMonthly = 0.0 @@ -87,13 +88,16 @@ export default async function Home() { try { const backendUrl = process.env.BACKEND_URL || 'http://backend:8000' const [configRes, reviewsRes] = await Promise.all([ + // Landing CTAs can lag registration changes by the existing one-hour cache. + // The backend still enforces ALLOW_REGISTRATION on every signup request. fetch(`${backendUrl}/api/config`, { next: { revalidate: 3600 } }), fetch(`${backendUrl}/api/reviews/public?limit=100`, { next: { revalidate: 300 }, - }), + }).catch(() => null), ]) if (configRes.ok) { const cfg = await configRes.json() + allowRegistration = cfg.allow_registration === true stripeEnabled = cfg.stripe_enabled ?? false trialDays = cfg.stripe_trial_days ?? 7 priceMonthly = cfg.price_monthly ?? 0.0 @@ -101,7 +105,7 @@ export default async function Home() { totalPriceMonthly = cfg.total_price_monthly ?? 0.0 totalPriceYearly = cfg.total_price_yearly ?? 0.0 } - if (reviewsRes.ok) { + if (reviewsRes?.ok) { reviews = await reviewsRes.json() } } catch { @@ -146,10 +150,20 @@ export default async function Home() {
- {hasSession ? t('dashboard') : tCommon('start')} + {hasSession + ? t('dashboard') + : allowRegistration + ? tCommon('start') + : t('signIn')}
( hasSession ? null : false ) @@ -259,14 +262,18 @@ export default function PricingSection({ ) : ( - {plan.cta} + {!hasSession && !allowRegistration + ? tLanding('signIn') + : plan.cta} )}
@@ -349,10 +356,12 @@ export default function PricingSection({ ) : ( - {tBilling(trialUsed ? 'ctaRegisterTrialUsed' : 'ctaRegister')} + {allowRegistration + ? tBilling(trialUsed ? 'ctaRegisterTrialUsed' : 'ctaRegister') + : tLanding('signIn')} )} {checkoutError && ( diff --git a/frontend/src/store/config.ts b/frontend/src/store/config.ts index 6f9c05de9..bdd733781 100644 --- a/frontend/src/store/config.ts +++ b/frontend/src/store/config.ts @@ -12,6 +12,7 @@ export interface DashboardBanner { } interface ConfigStore { + allowRegistration: boolean stripeEnabled: boolean stripeTrialDays: number freemiumTrialEnabled: boolean @@ -28,6 +29,7 @@ interface ConfigStore { } export const useConfigStore = create((set, get) => ({ + allowRegistration: false, stripeEnabled: false, stripeTrialDays: 7, freemiumTrialEnabled: true, @@ -47,6 +49,7 @@ export const useConfigStore = create((set, get) => ({ if (!res.ok) return const data = await res.json() set({ + allowRegistration: data.allow_registration === true, stripeEnabled: data.stripe_enabled ?? false, stripeTrialDays: data.stripe_trial_days ?? 7, freemiumTrialEnabled: data.freemium_trial_enabled ?? true, @@ -61,7 +64,7 @@ export const useConfigStore = create((set, get) => ({ loaded: true, }) } catch { - // Non-fatal: keep defaults (stripe disabled) + // Non-fatal: keep conservative presentation defaults set({ loaded: true }) } }, diff --git a/frontend/tests/app/landing.test.tsx b/frontend/tests/app/landing.test.tsx index f9938ff26..bb4426712 100644 --- a/frontend/tests/app/landing.test.tsx +++ b/frontend/tests/app/landing.test.tsx @@ -23,8 +23,15 @@ vi.mock('next/link', () => ({ React.createElement('a', { href, ...props }, children), })) -vi.mock('@/components/billing/PricingSection', () => ({ - default: () => null, +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock('@/lib/landing-subscription', () => ({ + getLandingSubscriptionState: async () => ({ + subscribed: true, + trialUsed: false, + }), })) vi.mock('@/components/ui/landing-faq', () => ({ @@ -36,7 +43,9 @@ vi.mock('@/components/ui/landing-nav', () => ({ })) vi.mock('@/components/ui/scroll-reveal', () => ({ - ScrollReveal: ({ children }: { children: React.ReactNode }) => <>{children}, + ScrollReveal: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), })) vi.mock('@/components/ui/contact-button', () => ({ @@ -53,14 +62,19 @@ vi.mock('@/components/reviews/LandingReviewsCarousel', () => ({ import Home from '@/app/page' +let config: Record +let configStatus: number + beforeEach(() => { + config = { allow_registration: true, stripe_enabled: false } + configStatus = 200 mockHas.mockReset().mockReturnValue(false) vi.stubGlobal( 'fetch', vi.fn(async (url: string) => { const { pathname } = new URL(url) if (pathname === '/api/config') { - return new Response(JSON.stringify({ stripe_enabled: false })) + return new Response(JSON.stringify(config), { status: configStatus }) } if (pathname === '/api/reviews/public') { return new Response(JSON.stringify([])) @@ -76,6 +90,69 @@ afterEach(() => { }) describe('Landing Home', () => { + it('keeps signup available when optional reviews cannot be fetched', async () => { + vi.mocked(fetch).mockImplementation(async (url) => { + if (String(url).endsWith('/api/config')) { + return new Response(JSON.stringify(config)) + } + throw new Error('reviews unavailable') + }) + render(await Home()) + expect(screen.getByRole('link', { name: 'start' })).toHaveAttribute( + 'href', + '/register' + ) + }) + + it('preserves all public pricing signup links and plan selection when enabled', async () => { + config.stripe_enabled = true + render(await Home()) + expect(screen.getByRole('link', { name: 'start' })).toHaveAttribute( + 'href', + '/register' + ) + expect(screen.getByRole('link', { name: 'planFreeCta' })).toHaveAttribute( + 'href', + '/register' + ) + expect( + screen + .getAllByRole('link', { name: 'ctaRegister' }) + .map((link) => link.getAttribute('href')) + ).toEqual([ + '/register?plan=monthly', + '/register?plan=yearly', + '/register?plan=yearly', + ]) + }) + + it('replaces the hero and every pricing signup CTA with Sign in when closed', async () => { + config = { allow_registration: false, stripe_enabled: true } + const { container } = render(await Home()) + expect(container.querySelector('a[href^="/register"]')).toBeNull() + const links = screen.getAllByRole('link', { name: 'signIn' }) + expect(links).toHaveLength(5) + links.forEach((link) => expect(link).toHaveAttribute('href', '/login')) + expect(screen.queryByText('start')).not.toBeInTheDocument() + expect(screen.queryByText('ctaRegister')).not.toBeInTheDocument() + }) + + it.each(['missing flag', 'HTTP error', 'network error'])( + 'does not advertise signup on %s', + async (failure) => { + config = {} + if (failure === 'HTTP error') configStatus = 503 + if (failure === 'network error') + vi.mocked(fetch).mockRejectedValue(new Error('offline')) + const { container } = render(await Home()) + expect(container.querySelector('a[href^="/register"]')).toBeNull() + expect(screen.getByRole('link', { name: 'signIn' })).toHaveAttribute( + 'href', + '/login' + ) + } + ) + it('shows the static microdemo and preserves anonymous CTAs', async () => { render(await Home()) @@ -111,20 +188,26 @@ describe('Landing Home', () => { ) }) - it('preserves the dashboard CTA for authenticated visitors', async () => { - mockHas.mockImplementation((name: string) => name === 'refresh_token') + it.each([true, false])( + 'preserves the dashboard CTA with registration=%s', + async (allowRegistration) => { + config.allow_registration = allowRegistration + mockHas.mockImplementation((name: string) => name === 'refresh_token') - render(await Home()) + render(await Home()) - expect(mockHas).toHaveBeenCalledWith('refresh_token') - expect(screen.getByRole('link', { name: 'dashboard' })).toHaveAttribute( - 'href', - '/dashboard' - ) - expect(screen.queryByRole('link', { name: 'start' })).not.toBeInTheDocument() - expect(screen.getByRole('link', { name: /howItWorks/ })).toHaveAttribute( - 'href', - '#features' - ) - }) + expect(mockHas).toHaveBeenCalledWith('refresh_token') + expect(screen.getByRole('link', { name: 'dashboard' })).toHaveAttribute( + 'href', + '/dashboard' + ) + expect( + screen.queryByRole('link', { name: 'start' }) + ).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: /howItWorks/ })).toHaveAttribute( + 'href', + '#features' + ) + } + ) }) diff --git a/frontend/tests/app/registration.test.tsx b/frontend/tests/app/registration.test.tsx new file mode 100644 index 000000000..965e72b00 --- /dev/null +++ b/frontend/tests/app/registration.test.tsx @@ -0,0 +1,308 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { useConfigStore } from '@/store/config' +import { useAuthStore } from '@/store/auth' + +const { searchParams, push, apiFetch } = vi.hoisted(() => ({ + searchParams: new URLSearchParams(), + push: vi.fn(), + apiFetch: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push }), + useSearchParams: () => searchParams, +})) + +vi.mock('next-intl', () => ({ + useTranslations: (namespace: string) => (key: string) => + `${namespace}.${key}`, +})) + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + ...props + }: React.AnchorHTMLAttributes) => + React.createElement('a', { href, ...props }, children), +})) + +vi.mock('@/lib/api', () => ({ apiFetch })) + +import RegisterPage from '@/app/(auth)/register/page' +import LoginPage from '@/app/(auth)/login/page' +import PrivacyPage from '@/app/(legal)/privacy/page' +import TermsPage from '@/app/(legal)/terms/page' + +function configResponse(allowRegistration: boolean) { + return new Response(JSON.stringify({ allow_registration: allowRegistration })) +} + +beforeEach(() => { + for (const key of Array.from(searchParams.keys())) searchParams.delete(key) + push.mockReset() + apiFetch.mockReset() + useConfigStore.setState({ ...useConfigStore.getInitialState() }, true) + useAuthStore.setState({ accessToken: null, user: null }) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(configResponse(false))) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +async function fillAndSubmit(container: HTMLElement) { + const form = container.querySelector('form')! + const inputs = form.querySelectorAll('input') + // Existing form order: username, display name, email, passwords, legal acceptance. + const values = [ + 'learner', + 'Learner', + 'learner@example.com', + 'Test1234!@', + 'Test1234!@', + ] + values.forEach((value, index) => + fireEvent.change(inputs[index], { target: { value } }) + ) + fireEvent.click(screen.getByRole('checkbox')) + await act(async () => fireEvent.submit(form)) +} + +describe('registration availability', () => { + it('shows loading without flashing a form or closed message, then opens public signup', async () => { + let resolveConfig!: (response: Response) => void + vi.mocked(fetch).mockReturnValueOnce( + new Promise((resolve) => { + resolveConfig = resolve + }) + ) + const { container } = render() + expect( + screen.getByRole('status', { name: 'common.loading' }) + ).toBeInTheDocument() + expect(container.querySelector('form')).toBeNull() + expect( + screen.queryByText('auth.register.registrationClosed') + ).not.toBeInTheDocument() + + await act(async () => resolveConfig(configResponse(true))) + expect( + screen.getByRole('button', { name: 'auth.register.submit' }) + ).toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith('/api/config') + }) + + it.each(['', 'invite='])( + 'shows the closed state without a supplied invite (%s)', + async (query) => { + if (query) searchParams.set('invite', '') + const { container } = render() + expect( + await screen.findByText('auth.register.registrationClosed') + ).toBeInTheDocument() + expect( + screen.getByRole('link', { name: 'auth.register.login' }) + ).toHaveAttribute('href', '/login') + expect(container.querySelector('form')).toBeNull() + expect(apiFetch).not.toHaveBeenCalled() + } + ) + + it.each(['network', 'HTTP', 'missing flag'])( + 'keeps ordinary signup closed after a %s failure', + async (failure) => { + if (failure === 'network') + vi.mocked(fetch).mockRejectedValueOnce(new Error('offline')) + if (failure === 'HTTP') + vi.mocked(fetch).mockResolvedValueOnce( + new Response('', { status: 503 }) + ) + if (failure === 'missing flag') + vi.mocked(fetch).mockResolvedValueOnce(new Response('{}')) + const { container } = render() + expect( + await screen.findByText('auth.register.registrationClosed') + ).toBeInTheDocument() + expect(container.querySelector('form')).toBeNull() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + } + ) + + it('opens an unvalidated invite immediately even while config is unavailable', async () => { + searchParams.set('invite', 'supplied-token') + vi.mocked(fetch).mockReturnValueOnce(new Promise(() => {})) + render() + expect( + screen.getByRole('button', { name: 'auth.register.submit' }) + ).toBeInTheDocument() + expect( + screen.queryByText('auth.register.registrationClosed') + ).not.toBeInTheDocument() + expect(apiFetch).not.toHaveBeenCalled() + }) + + it.each([null, 'monthly', 'yearly'])( + 'preserves public registration and onboarding plan=%s', + async (plan) => { + vi.mocked(fetch).mockResolvedValueOnce(configResponse(true)) + if (plan) searchParams.set('plan', plan) + apiFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: 'new-token' })) + ) + const { container } = render() + await screen.findByRole('button', { name: 'auth.register.submit' }) + await fillAndSubmit(container) + expect(apiFetch).toHaveBeenCalledWith( + '/api/auth/register', + expect.objectContaining({ method: 'POST' }) + ) + expect(JSON.parse(apiFetch.mock.calls[0][1].body)).not.toHaveProperty( + 'invite_token' + ) + expect(useAuthStore.getState().accessToken).toBe('new-token') + expect(push).toHaveBeenCalledWith( + plan ? `/onboarding?plan=${plan}` : '/onboarding' + ) + } + ) + + it.each([true, false])( + 'passes an invite to the backend and respects its decision (accepted=%s)', + async (accepted) => { + searchParams.set('invite', 'unvalidated-token') + apiFetch.mockResolvedValueOnce( + new Response( + JSON.stringify( + accepted + ? { access_token: 'invited-token' } + : { detail: 'Invalid or expired invite' } + ), + { status: accepted ? 200 : 403 } + ) + ) + const { container } = render() + await waitFor(() => expect(useConfigStore.getState().loaded).toBe(true)) + expect( + screen.queryByText('auth.register.registrationClosed') + ).not.toBeInTheDocument() + await fillAndSubmit(container) + expect(JSON.parse(apiFetch.mock.calls[0][1].body).invite_token).toBe( + 'unvalidated-token' + ) + if (accepted) { + expect(push).toHaveBeenCalledWith('/onboarding') + } else { + expect( + await screen.findByText(/auth.register.invalidInvite/) + ).toBeInTheDocument() + expect(push).not.toHaveBeenCalled() + expect(useAuthStore.getState().accessToken).toBeNull() + } + } + ) + + it.each([true, false])( + 'shows the login signup link only for registration=%s', + async (allowRegistration) => { + vi.mocked(fetch).mockResolvedValueOnce(configResponse(allowRegistration)) + render() + await waitFor(() => expect(useConfigStore.getState().loaded).toBe(true)) + if (allowRegistration) { + expect( + screen.getByRole('link', { name: 'auth.login.register' }) + ).toHaveAttribute('href', '/register') + } else { + expect( + screen.queryByRole('link', { name: 'auth.login.register' }) + ).not.toBeInTheDocument() + expect( + screen.queryByText('auth.login.noAccount') + ).not.toBeInTheDocument() + } + expect( + screen.getByRole('button', { name: 'auth.login.submit' }) + ).toBeInTheDocument() + expect( + screen.getByRole('link', { name: 'auth.login.forgotPassword' }) + ).toHaveAttribute('href', '/forgot-password') + } + ) + + it('preserves the supplied invite in registration legal links', async () => { + searchParams.set('invite', 'token+with/symbols') + render() + await waitFor(() => expect(useConfigStore.getState().loaded).toBe(true)) + for (const page of ['terms', 'privacy']) { + expect( + screen.getByRole('link', { name: `auth.register.${page}Link` }) + ).toHaveAttribute( + 'href', + `/${page}?from=register&invite=token%2Bwith%2Fsymbols` + ) + } + }) +}) + +describe.each([ + ['privacy', PrivacyPage, 'terms'], + ['terms', TermsPage, 'privacy'], +] as const)('%s return navigation', (page, Page, other) => { + it.each([true, false])( + 'returns ordinary visitors appropriately for registration=%s', + async (allowRegistration) => { + searchParams.set('from', 'register') + vi.mocked(fetch).mockResolvedValueOnce(configResponse(allowRegistration)) + const { container } = render() + await waitFor(() => expect(useConfigStore.getState().loaded).toBe(true)) + if (allowRegistration) { + expect( + screen.getByRole('link', { name: `legal.${page}.linkBack` }) + ).toHaveAttribute('href', '/register') + } else { + expect(container.querySelector('a[href^="/register"]')).toBeNull() + expect( + screen.getByRole('link', { name: 'auth.register.login' }) + ).toHaveAttribute('href', '/login') + } + } + ) + + it('preserves invites between legal pages and back to the form when closed', async () => { + searchParams.set('from', 'register') + searchParams.set('invite', 'token+with/symbols') + render() + await waitFor(() => expect(useConfigStore.getState().loaded).toBe(true)) + expect( + screen.getByRole('link', { name: `legal.${page}.linkBack` }) + ).toHaveAttribute('href', '/register?invite=token%2Bwith%2Fsymbols') + expect( + screen.getByRole('link', { + name: `legal.${page}.link${other === 'terms' ? 'Terms' : 'Privacy'}`, + }) + ).toHaveAttribute( + 'href', + `/${other}?from=register&invite=token%2Bwith%2Fsymbols` + ) + }) + + it('preserves the authenticated Settings return link', () => { + searchParams.set('from', 'settings') + render() + expect( + screen.getByRole('link', { name: `legal.${page}.linkBackSettings` }) + ).toHaveAttribute('href', '/settings') + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/tests/components/BillingPaywall.test.tsx b/frontend/tests/components/BillingPaywall.test.tsx index 2789dbb12..11959da84 100644 --- a/frontend/tests/components/BillingPaywall.test.tsx +++ b/frontend/tests/components/BillingPaywall.test.tsx @@ -389,41 +389,45 @@ describe('billing paywall UI', () => { ) }) - it('starts Checkout directly from landing pricing when the user has a session', async () => { - mockLandingSubscriptionState.mockResolvedValueOnce({ - subscribed: false, - trialUsed: false, - }) - mockApiFetch.mockResolvedValueOnce( - jsonResponse({ url: 'https://checkout.stripe.com/pay/monthly' }) - ) + it.each([true, false])( + 'starts Checkout directly with a session and registration=%s', + async (allowRegistration) => { + mockLandingSubscriptionState.mockResolvedValueOnce({ + subscribed: false, + trialUsed: false, + }) + mockApiFetch.mockResolvedValueOnce( + jsonResponse({ url: 'https://checkout.stripe.com/pay/monthly' }) + ) - render( - - ) + render( + + ) - await waitFor(() => - expect(screen.getAllByText('ctaRegister')).toHaveLength(3) - ) - fireEvent.click(screen.getAllByText('ctaRegister')[0]) + await waitFor(() => + expect(screen.getAllByText('ctaRegister')).toHaveLength(3) + ) + fireEvent.click(screen.getAllByText('ctaRegister')[0]) - await waitFor(() => - expect(mockApiFetch).toHaveBeenCalledWith('/api/billing/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ plan: 'monthly' }), - }) - ) - expect(window.location.assign).toHaveBeenCalledWith( - 'https://checkout.stripe.com/pay/monthly' - ) - }) + await waitFor(() => + expect(mockApiFetch).toHaveBeenCalledWith('/api/billing/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ plan: 'monthly' }), + }) + ) + expect(window.location.assign).toHaveBeenCalledWith( + 'https://checkout.stripe.com/pay/monthly' + ) + } + ) }) diff --git a/frontend/tests/store/config.test.ts b/frontend/tests/store/config.test.ts index 021c80b88..9a70f0d5a 100644 --- a/frontend/tests/store/config.test.ts +++ b/frontend/tests/store/config.test.ts @@ -6,6 +6,7 @@ describe('useConfigStore', () => { beforeEach(() => { useConfigStore.setState({ + allowRegistration: false, stripeEnabled: false, stripeTrialDays: 7, freemiumTrialEnabled: true, @@ -27,6 +28,7 @@ describe('useConfigStore', () => { vi.mocked(fetch).mockResolvedValueOnce( new Response( JSON.stringify({ + allow_registration: true, stripe_enabled: true, stripe_trial_days: 14, tts_provider: 'openai', @@ -46,6 +48,7 @@ describe('useConfigStore', () => { await useConfigStore.getState().load() + expect(useConfigStore.getState().allowRegistration).toBe(true) expect(useConfigStore.getState().stripeEnabled).toBe(true) expect(useConfigStore.getState().stripeTrialDays).toBe(14) expect(useConfigStore.getState().ttsProvider).toBe('openai') @@ -56,6 +59,29 @@ describe('useConfigStore', () => { expect(useConfigStore.getState().loaded).toBe(true) }) + it('keeps signup closed until configuration arrives', async () => { + let resolveConfig!: (response: Response) => void + vi.mocked(fetch).mockReturnValueOnce( + new Promise((resolve) => { + resolveConfig = resolve + }) + ) + const loading = useConfigStore.getState().load() + expect(useConfigStore.getState().allowRegistration).toBe(false) + resolveConfig(new Response(JSON.stringify({ allow_registration: true }))) + await loading + expect(useConfigStore.getState().allowRegistration).toBe(true) + }) + + it('loads closed registration even if the previous state allowed it', async () => { + useConfigStore.setState({ allowRegistration: true }) + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ allow_registration: false })) + ) + await useConfigStore.getState().load() + expect(useConfigStore.getState().allowRegistration).toBe(false) + }) + it('does not fetch twice (idempotency)', async () => { vi.mocked(fetch).mockResolvedValue( new Response(JSON.stringify({ stripe_enabled: true }), { @@ -75,6 +101,7 @@ describe('useConfigStore', () => { await useConfigStore.getState().load() + expect(useConfigStore.getState().allowRegistration).toBe(false) expect(useConfigStore.getState().stripeEnabled).toBe(false) expect(useConfigStore.getState().ttsProvider).toBe('local') expect(useConfigStore.getState().loaded).toBe(true) @@ -87,6 +114,7 @@ describe('useConfigStore', () => { await useConfigStore.getState().load() + expect(useConfigStore.getState().allowRegistration).toBe(false) expect(useConfigStore.getState().stripeEnabled).toBe(false) expect(useConfigStore.getState().loaded).toBe(false) }) @@ -101,6 +129,7 @@ describe('useConfigStore', () => { await useConfigStore.getState().load() + expect(useConfigStore.getState().allowRegistration).toBe(false) expect(useConfigStore.getState().stripeEnabled).toBe(false) expect(useConfigStore.getState().stripeTrialDays).toBe(7) expect(useConfigStore.getState().ttsProvider).toBe('local') diff --git a/specs/api-endpoints.instructions.md b/specs/api-endpoints.instructions.md index f52306936..66a1b6e7f 100644 --- a/specs/api-endpoints.instructions.md +++ b/specs/api-endpoints.instructions.md @@ -17,7 +17,7 @@ Most REST endpoints are prefixed under `/api`. The public health check is at `/h ## Config — `/api/config` -- **GET `/api/config`** — Rate limit: 60/min. Public runtime configuration flags for the frontend. Returns non-sensitive values including Stripe enablement/prices, TTS provider/voice, `maintenance_mode`, `freemium_trial_enabled`, and `dashboard_banner`. The banner field is `null` when no active singleton exists; otherwise it is `{revision, translations}` and omits admin-only source locale, active state, and timestamps. +- **GET `/api/config`** — Rate limit: 60/min. Public runtime configuration flags for the frontend. Returns non-sensitive values including `allow_registration` (boolean, from `settings.ALLOW_REGISTRATION`), Stripe enablement/prices, TTS provider/voice, `maintenance_mode`, `freemium_trial_enabled`, and `dashboard_banner`. The banner field is `null` when no active singleton exists; otherwise it is `{revision, translations}` and omits admin-only source locale, active state, and timestamps. --- diff --git a/specs/architecture-frontend.instructions.md b/specs/architecture-frontend.instructions.md index f421bbfd7..33195b804 100644 --- a/specs/architecture-frontend.instructions.md +++ b/specs/architecture-frontend.instructions.md @@ -70,7 +70,7 @@ direction, and optional reading behavior remain centralized. Zustand stores shared cross-route state: - `auth`: access token and current mapped user. -- `config`: public runtime presentation flags and dashboard announcement. +- `config`: public runtime presentation flags, including `allowRegistration` (default false), and dashboard announcement. - `freemium`: cached quota and trial status. - `language`: active language, user languages, available codes, and language mutations. - `loading`: request counter and loading-bar completion state. @@ -80,6 +80,15 @@ Zustand stores shared cross-route state: Screen-specific forms, async state, playback, selections, and modal state remain local React state. Do not promote local state into a global store without a cross-route requirement. +## Public registration surfaces + +The server-rendered landing page retains its one-hour `/api/config` revalidation and passes +`allowRegistration` to pricing. Login, registration, and registration-origin legal pages load the +config store. Public signup links use the flag, while dashboard and authenticated checkout actions retain their session +behavior. The registration page gates the form for ordinary visitors and accepts any nonempty +`invite` query parameter without frontend validation. Legal links carry that invite through the +terms/privacy pages and back to registration. All closed-state copy reuses existing locale keys. + ## Authenticated shell The app layout resolves the session, loads the current profile, enforces onboarding completion, diff --git a/specs/platform.instructions.md b/specs/platform.instructions.md index 29ada355a..749e7be01 100644 --- a/specs/platform.instructions.md +++ b/specs/platform.instructions.md @@ -31,7 +31,15 @@ offer assessment rather than inventing a plan or silently selecting another lang ## Authentication and session Public registration is controlled by `ALLOW_REGISTRATION`. A valid single-use invitation bypasses a -closed public-registration gate. Email-domain blocking runs before user creation. When enabled, +closed public-registration gate. The public config flag `allow_registration` reflects this setting. +When false, public signup actions lead to Login or are hidden, and `/register` without a nonempty +`invite` query parameter shows the localized closed-registration message and a Login action. Any +supplied invite opens the existing form; only the backend validates and consumes the token. Legal +page links preserve the supplied invite so reading the terms or privacy policy does not lose it. +When true, the existing public signup journey and pricing plan selection remain available. +Authenticated dashboard and checkout actions do not depend on this flag. + +Email-domain blocking runs before user creation. When enabled, `FIRST_USER_IS_ADMIN` assigns the first registered account the administrator role. Registration accepts account data and optional target language, creates the user, returns an access @@ -185,12 +193,16 @@ Target-language metadata and typography are specified separately from UI localiz ## Runtime configuration Backend `Settings` and environment variables are private configuration. `/api/config` exposes only -presentation-safe runtime data such as billing flags/prices, freemium trial state, TTS presentation, -maintenance state, and active announcement. Available target-language codes come from -`/api/languages`, not public config. - -The frontend config store loads runtime state with conservative presentation defaults. Client flags -can be stale and never authorize an operation; backend dependencies remain authoritative. +presentation-safe runtime data such as public-registration availability, billing flags/prices, +freemium trial state, TTS presentation, maintenance state, and active announcement. Available +target-language codes come from `/api/languages`, not public config. + +The frontend config store loads runtime state with conservative presentation defaults, including +`allowRegistration=false` until the backend explicitly enables it. Registration without an invite +shows loading while its config request is pending; failed requests or missing flags keep the form +closed. Login remains available, and supplied invites do not wait for config. The landing page retains +its one-hour config revalidation, so its signup CTAs can lag a setting change. Client flags can be +stale and never authorize an operation; backend dependencies remain authoritative. Redis supports session rotation, invitations, rate limiting, quotas, and runtime operational state.