diff --git a/AGENTS.md b/AGENTS.md
index 9708e912a..2f92d4cea 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# AGENTS.md — FreeLingo
-**Current version: 1.9.15**
+**Current version: 1.9.20**
## Project
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0941c0169..1a9756650 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ 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).
+## [1.9.20] - 2026-09-24
+
+### Fixed
+
+- Onboarding learning goals now name the selected language in all ten interface languages, with natural wording and a safe display fallback for unrecognized language codes.
+- 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`.
+- Configuration loading can retry after temporary network or invalid JSON failures, so registration and billing controls recover on subsequent navigation without requiring a full page reload.
+- Blocked email-domain registration uses the current HTTP 422 status constant, avoiding the deprecated Starlette alias while preserving the response code and message.
+- Flashcard concurrent-deletion tests detach the deleted object before promotion, avoiding SQLAlchemy identity-map collisions when SQLite reuses its ID while preserving the simulated deletion scenario.
+
## [1.9.15] - 2026-09-22
### Changed
diff --git a/README.md b/README.md
index 82daebd96..5617e636b 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@



-
+
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index 779fa9845..e38492534 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -79,7 +79,7 @@ async def register(
email_domain = data.email.split("@")[-1].lower()
if email_domain in [d.lower() for d in settings.BLOCKED_EMAIL_DOMAINS]:
raise HTTPException(
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Email domain not allowed",
)
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/backend/tests/test_flashcards.py b/backend/tests/test_flashcards.py
index 3a752d203..af60fa3f7 100644
--- a/backend/tests/test_flashcards.py
+++ b/backend/tests/test_flashcards.py
@@ -795,6 +795,8 @@ async def delete_then_promote(db, existing):
.execution_options(synchronize_session=False)
)
await db.commit()
+ # Keep the stale reference without an identity-map collision if SQLite reuses its ID.
+ db.expunge(existing)
return await respond(db, existing)
async def lookup(**kwargs):
diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx
index 15cf0eee7..7dc83d92e 100644
--- a/frontend/src/app/(app)/layout.tsx
+++ b/frontend/src/app/(app)/layout.tsx
@@ -379,7 +379,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
- v1.9.15
+ v1.9.20
setContactOpen(true)}
@@ -573,7 +573,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
)}
- v1.9.15
+ v1.9.20
{
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')}
+
+
+ )}
s.setUser)
@@ -64,6 +68,11 @@ export default function OnboardingPage() {
useState(null)
const [checkoutError, setCheckoutError] = useState('')
+ const targetLanguageName = tLang(
+ getLanguageByCode(targetLanguage)?.iso639 ??
+ DEFAULT_TARGET_LANGUAGE.split('-')[0]
+ )
+
useEffect(() => {
loadConfig()
fetchLanguages().then(() => {
@@ -266,7 +275,7 @@ export default function OnboardingPage() {
{step === 2 && (
- {t('goals.subtitle')}
+ {t('goals.subtitle', { language: targetLanguageName })}
{LEARNING_GOALS.map((goal) => {
diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx
index 522d30636..82c9a178f 100644
--- a/frontend/src/app/(auth)/register/page.tsx
+++ b/frontend/src/app/(auth)/register/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'
@@ -8,6 +8,8 @@ import { useTranslations } from 'next-intl'
import { Loader2 } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useAuthStore } from '@/store/auth'
+import { useConfigStore } from '@/store/config'
+import { PageLoading } from '@/components/ui/page-loading'
const LANGUAGES = [
'en',
@@ -358,14 +360,22 @@ function RegisterForm() {
>
{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
+
+
+ {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/components/whats-new/WhatsNew.tsx b/frontend/src/components/whats-new/WhatsNew.tsx
index 70aca6270..d0cd8b146 100644
--- a/frontend/src/components/whats-new/WhatsNew.tsx
+++ b/frontend/src/components/whats-new/WhatsNew.tsx
@@ -5,7 +5,7 @@ import Image from 'next/image'
import { useTranslations, useMessages } from 'next-intl'
import { CircleDot } from 'lucide-react'
-const WHATS_NEW_VERSION = 'v1.9.15'
+const WHATS_NEW_VERSION = 'v1.9.20'
const STORAGE_KEY = `fl_whats_new_seen_${WHATS_NEW_VERSION}`
const TOUR_KEY = 'fl_tour_done'
diff --git a/frontend/src/store/config.ts b/frontend/src/store/config.ts
index 6f9c05de9..812df6556 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,8 +64,7 @@ export const useConfigStore = create((set, get) => ({
loaded: true,
})
} catch {
- // Non-fatal: keep defaults (stripe disabled)
- set({ loaded: true })
+ // Non-fatal: keep conservative defaults and allow the next load to retry.
}
},
}))
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/onboarding-goals-subtitle.test.tsx b/frontend/tests/app/onboarding-goals-subtitle.test.tsx
new file mode 100644
index 000000000..1a25a1370
--- /dev/null
+++ b/frontend/tests/app/onboarding-goals-subtitle.test.tsx
@@ -0,0 +1,181 @@
+import React from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { NextIntlClientProvider } from 'next-intl'
+import { TARGET_LANGUAGE_CATALOG } from '@/lib/target-languages'
+import { useAuthStore } from '@/store/auth'
+import { useConfigStore } from '@/store/config'
+import { useLanguageStore } from '@/store/language'
+import de from '../../../messages/de.json'
+import en from '../../../messages/en.json'
+import es from '../../../messages/es.json'
+import fr from '../../../messages/fr.json'
+import itMessages from '../../../messages/it.json'
+import nl from '../../../messages/nl.json'
+import pl from '../../../messages/pl.json'
+import pt from '../../../messages/pt.json'
+import ro from '../../../messages/ro.json'
+import ru from '../../../messages/ru.json'
+
+const { searchParams, apiFetch } = vi.hoisted(() => ({
+ searchParams: new URLSearchParams(),
+ apiFetch: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: vi.fn() }),
+ useSearchParams: () => searchParams,
+}))
+
+vi.mock('@/lib/api', () => ({ apiFetch }))
+
+import OnboardingPage from '@/app/(auth)/onboarding/page'
+
+const catalogs = { de, en, es, fr, it: itMessages, nl, pl, pt, ro, ru }
+type Locale = keyof typeof catalogs
+
+const spanishSubtitles: Record = {
+ de: 'Sprache: Spanisch. Wofür möchtest du sie nutzen? Wähle alle zutreffenden Ziele aus.',
+ en: 'Language: Spanish. What do you want to use it for? Select all that apply.',
+ es: 'Idioma: español. ¿Para qué quieres utilizarlo? Selecciona todos los objetivos que correspondan.',
+ fr: "Langue : espagnol. Dans quel but souhaitez-vous l'utiliser ? Sélectionnez tous les objectifs qui vous correspondent.",
+ it: 'Lingua: spagnolo. Per cosa vuoi usarla? Seleziona tutti gli obiettivi che fanno al caso tuo.',
+ nl: 'Taal: Spaans. Waarvoor wil je deze taal gebruiken? Selecteer alle doelen die van toepassing zijn.',
+ pl: 'Język: hiszpański. Do czego chcesz go używać? Zaznacz wszystkie cele, które Ci odpowiadają.',
+ pt: 'Idioma: espanhol. Para que você quer usá-lo? Selecione todos os objetivos que se aplicam.',
+ ro: 'Limbă: spaniolă. Pentru ce vrei să o folosești? Selectează toate obiectivele care ți se potrivesc.',
+ ru: 'Язык: испанский. Для чего ты хочешь его использовать? Выбери все подходящие цели.',
+}
+
+const onIntlError = vi.fn()
+const englishSubtitle =
+ 'Language: English. What do you want to use it for? Select all that apply.'
+
+function languageResponse(
+ codes = TARGET_LANGUAGE_CATALOG.map((language) => language.code)
+) {
+ return new Response(
+ JSON.stringify({ languages: [], all_supported_languages: codes })
+ )
+}
+
+function renderOnboarding(locale: Locale = 'en') {
+ return render(
+
+
+
+ )
+}
+
+beforeEach(() => {
+ for (const key of Array.from(searchParams.keys())) searchParams.delete(key)
+ onIntlError.mockReset()
+ apiFetch.mockReset()
+ apiFetch.mockImplementation(async () => languageResponse())
+ useAuthStore.setState({ ...useAuthStore.getInitialState() }, true)
+ useConfigStore.setState(
+ { ...useConfigStore.getInitialState(), loaded: true },
+ true
+ )
+ useLanguageStore.setState({ ...useLanguageStore.getInitialState() }, true)
+})
+
+afterEach(() => {
+ cleanup()
+ expect(onIntlError).not.toHaveBeenCalled()
+})
+
+describe('onboarding goals subtitle', () => {
+ it.each(Object.keys(catalogs) as Locale[])(
+ 'names the selected language with natural wording in the %s UI',
+ async (locale) => {
+ const messages = catalogs[locale]
+ renderOnboarding(locale)
+
+ fireEvent.click(
+ await screen.findByRole('button', {
+ name: new RegExp(messages.targetLanguages['es-ES']),
+ })
+ )
+ fireEvent.click(
+ screen.getByRole('button', { name: messages.common.next })
+ )
+
+ expect(screen.getByText(spanishSubtitles[locale])).toBeInTheDocument()
+ }
+ )
+
+ it.each([
+ [null, 'English'],
+ ['en-GB', 'English'],
+ ['en-US', 'English'],
+ ['de-DE', 'German'],
+ ['fr-FR', 'French'],
+ ['it-IT', 'Italian'],
+ ['pt-PT', 'Portuguese'],
+ ['ja-JP', 'Japanese'],
+ ['ko-KR', 'Korean'],
+ ['zh-CN', 'Chinese'],
+ ])(
+ 'uses the language-level name for query language=%s',
+ async (code, name) => {
+ if (code) searchParams.set('language', code)
+ renderOnboarding()
+ await screen.findByRole('button', { name: /Spanish/ })
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }))
+
+ expect(
+ screen.getByText(
+ `Language: ${name}. What do you want to use it for? Select all that apply.`
+ )
+ ).toBeInTheDocument()
+ }
+ )
+
+ it.each(['', 'unknown-language'])(
+ 'safely displays the default for language=%s while languages load',
+ async (code) => {
+ searchParams.set('language', code)
+ let resolveLanguages!: (response: Response) => void
+ apiFetch.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveLanguages = resolve
+ })
+ )
+ renderOnboarding()
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }))
+
+ expect(screen.getByText(englishSubtitle)).toBeInTheDocument()
+
+ await act(async () => resolveLanguages(languageResponse(['fr-FR'])))
+
+ expect(
+ screen.getByText(
+ 'Language: French. What do you want to use it for? Select all that apply.'
+ )
+ ).toBeInTheDocument()
+ }
+ )
+
+ it.each(['HTTP', 'network'])(
+ 'safely displays the default for an invalid query after a %s failure',
+ async (failure) => {
+ searchParams.set('language', 'unknown-language')
+ if (failure === 'HTTP') {
+ apiFetch.mockResolvedValueOnce(new Response('', { status: 503 }))
+ } else {
+ apiFetch.mockRejectedValueOnce(new Error('offline'))
+ }
+ renderOnboarding()
+ await screen.findByText(en.onboarding.saveFailed)
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }))
+
+ expect(screen.getByText(englishSubtitle)).toBeInTheDocument()
+ }
+ )
+})
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..ba6e20c5e 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 }), {
@@ -70,16 +96,46 @@ describe('useConfigStore', () => {
expect(fetch).toHaveBeenCalledTimes(1)
})
- it('keeps defaults when fetch fails', async () => {
+ it('keeps defaults and allows retry when fetch fails', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('network error'))
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)
+ expect(useConfigStore.getState().loaded).toBe(false)
})
+ it.each(['network error', 'invalid JSON'])(
+ 'recovers registration and billing configuration after %s',
+ async (failure) => {
+ if (failure === 'network error') {
+ vi.mocked(fetch).mockRejectedValueOnce(new Error('offline'))
+ } else {
+ vi.mocked(fetch).mockResolvedValueOnce(new Response('invalid JSON'))
+ }
+ vi.mocked(fetch).mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({ allow_registration: true, stripe_enabled: true })
+ )
+ )
+
+ await useConfigStore.getState().load()
+
+ expect(useConfigStore.getState().allowRegistration).toBe(false)
+ expect(useConfigStore.getState().stripeEnabled).toBe(false)
+ expect(useConfigStore.getState().loaded).toBe(false)
+
+ await useConfigStore.getState().load()
+
+ expect(fetch).toHaveBeenCalledTimes(2)
+ expect(useConfigStore.getState().allowRegistration).toBe(true)
+ expect(useConfigStore.getState().stripeEnabled).toBe(true)
+ expect(useConfigStore.getState().loaded).toBe(true)
+ }
+ )
+
it('does not mark loaded when response is not ok (allows retry)', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response('error', { status: 500 })
@@ -87,6 +143,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 +158,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/messages/de.json b/messages/de.json
index b9e1c0de3..aa2f62af4 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1295,7 +1295,7 @@
"saveFailed": "Einstellungen konnten nicht gespeichert werden",
"goals": {
"title": "Deine Lernziele",
- "subtitle": "Wozu möchtest du Englisch nutzen? Wähle alles Zutreffende.",
+ "subtitle": "Sprache: {language}. Wofür möchtest du sie nutzen? Wähle alle zutreffenden Ziele aus.",
"skip": "Überspringen",
"travel": "Reisen & Tourismus",
"work": "Beruf & Karriere",
@@ -1486,7 +1486,7 @@
},
"whatsNew": {
"title": "Neuigkeiten",
- "version": "v1.9.15",
+ "version": "v1.9.20",
"cta": "Verstanden",
"entry1": {
"label": "Ausgewogenere Lernpläne",
@@ -1691,4 +1691,4 @@
"title": "Wartungsarbeiten",
"description": "Dieser Bereich ist wegen Wartungsarbeiten vorübergehend nicht verfügbar. Bitte versuche es später erneut."
}
-}
+}
\ No newline at end of file
diff --git a/messages/en.json b/messages/en.json
index cbf8fe81a..ce3eaf3d2 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1295,7 +1295,7 @@
"saveFailed": "Failed to save preferences",
"goals": {
"title": "Your Learning Goals",
- "subtitle": "What do you want to use English for? Select all that apply.",
+ "subtitle": "Language: {language}. What do you want to use it for? Select all that apply.",
"skip": "Skip",
"travel": "Travel & Tourism",
"work": "Work & Professional",
@@ -1486,7 +1486,7 @@
},
"whatsNew": {
"title": "What's New",
- "version": "v1.9.15",
+ "version": "v1.9.20",
"cta": "Got it",
"entry1": {
"label": "Better-balanced study plans",
@@ -1691,4 +1691,4 @@
"title": "Under Maintenance",
"description": "This section is temporarily unavailable while we perform maintenance. Please check back later."
}
-}
+}
\ No newline at end of file
diff --git a/messages/es.json b/messages/es.json
index a7cda5d2e..b27a12406 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1295,7 +1295,7 @@
"saveFailed": "Error al guardar las preferencias",
"goals": {
"title": "Tus objetivos de aprendizaje",
- "subtitle": "¿Para qué quieres usar el inglés? Selecciona todos los que apliquen.",
+ "subtitle": "Idioma: {language}. ¿Para qué quieres utilizarlo? Selecciona todos los objetivos que correspondan.",
"skip": "Omitir",
"travel": "Viajes y turismo",
"work": "Trabajo y carrera",
@@ -1486,7 +1486,7 @@
},
"whatsNew": {
"title": "Novedades",
- "version": "v1.9.15",
+ "version": "v1.9.20",
"cta": "Entendido",
"entry1": {
"label": "Planes mejor equilibrados",
@@ -1691,4 +1691,4 @@
"title": "En mantenimiento",
"description": "Esta sección no está disponible temporalmente por tareas de mantenimiento. Vuelve más tarde."
}
-}
+}
\ No newline at end of file
diff --git a/messages/fr.json b/messages/fr.json
index aac8dcc4b..1989d51ad 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1295,7 +1295,7 @@
"saveFailed": "Échec de l'enregistrement des préférences",
"goals": {
"title": "Vos objectifs d'apprentissage",
- "subtitle": "Pour quoi voulez-vous utiliser l'anglais ? Sélectionnez tout ce qui s'applique.",
+ "subtitle": "Langue : {language}. Dans quel but souhaitez-vous l'utiliser ? Sélectionnez tous les objectifs qui vous correspondent.",
"skip": "Passer",
"travel": "Voyages et tourisme",
"work": "Travail et carrière",
@@ -1486,7 +1486,7 @@
},
"whatsNew": {
"title": "Nouveautés",
- "version": "v1.9.15",
+ "version": "v1.9.20",
"cta": "Compris",
"entry1": {
"label": "Des plans mieux équilibrés",
@@ -1691,4 +1691,4 @@
"title": "En maintenance",
"description": "Cette section est temporairement indisponible pour cause de maintenance. Veuillez réessayer plus tard."
}
-}
+}
\ No newline at end of file
diff --git a/messages/it.json b/messages/it.json
index 382326ea8..572c1a756 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1295,7 +1295,7 @@
"saveFailed": "Impossibile salvare le preferenze",
"goals": {
"title": "I tuoi obiettivi",
- "subtitle": "Per cosa vuoi usare l'inglese? Seleziona tutto ciò che si applica.",
+ "subtitle": "Lingua: {language}. Per cosa vuoi usarla? Seleziona tutti gli obiettivi che fanno al caso tuo.",
"skip": "Salta",
"travel": "Viaggi e turismo",
"work": "Lavoro e carriera",
@@ -1486,7 +1486,7 @@
},
"whatsNew": {
"title": "Novità",
- "version": "v1.9.15",
+ "version": "v1.9.20",
"cta": "Capito",
"entry1": {
"label": "Piani più equilibrati",
@@ -1691,4 +1691,4 @@
"title": "In manutenzione",
"description": "Questa sezione è temporaneamente non disponibile per manutenzione. Riprova più tardi."
}
-}
+}
\ No newline at end of file
diff --git a/messages/nl.json b/messages/nl.json
index 3a4fbb3f7..84196e65a 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -1295,7 +1295,7 @@
"saveFailed": "Opslaan van voorkeuren mislukt",
"goals": {
"title": "Jouw leerdoelen",
- "subtitle": "Waarvoor wil je Engels gebruiken? Selecteer alles wat van toepassing is.",
+ "subtitle": "Taal: {language}. Waarvoor wil je deze taal gebruiken? Selecteer alle doelen die van toepassing zijn.",
"skip": "Overslaan",
"travel": "Reizen en toerisme",
"work": "Werk en carrière",
@@ -1486,7 +1486,7 @@
},
"whatsNew": {
"title": "Nieuw",
- "version": "v1.9.15",
+ "version": "v1.9.20",
"cta": "Begrepen",
"entry1": {
"label": "Studieplannen met meer balans",
@@ -1691,4 +1691,4 @@
"title": "In onderhoud",
"description": "Dit gedeelte is tijdelijk niet beschikbaar vanwege onderhoud. Probeer het later opnieuw."
}
-}
+}
\ No newline at end of file
diff --git a/messages/pl.json b/messages/pl.json
index bcf7ca834..11e10f47d 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1295,7 +1295,7 @@
"saveFailed": "Nie udało się zapisać preferencji",
"goals": {
"title": "Twoje cele nauki",
- "subtitle": "Do czego chcesz używać angielskiego? Zaznacz wszystkie, które dotyczą Ciebie.",
+ "subtitle": "Język: {language}. Do czego chcesz go używać? Zaznacz wszystkie cele, które Ci odpowiadają.",
"skip": "Pominąć",
"travel": "Podróże i turystyka",
"work": "Praca i kariera",
@@ -1486,7 +1486,7 @@
},
"whatsNew": {
"title": "Co nowego",
- "version": "v1.9.15",
+ "version": "v1.9.20",
"cta": "Rozumiem",
"entry1": {
"label": "Lepiej zrównoważone plany nauki",
@@ -1691,4 +1691,4 @@
"title": "W trakcie konserwacji",
"description": "Ta sekcja jest tymczasowo niedostępna z powodu prac konserwacyjnych. Spróbuj ponownie później."
}
-}
+}
\ No newline at end of file
diff --git a/messages/pt.json b/messages/pt.json
index f82d1be2e..b93d321c8 100644
--- a/messages/pt.json
+++ b/messages/pt.json
@@ -1295,7 +1295,7 @@
"saveFailed": "Falha ao salvar as preferências",
"goals": {
"title": "Seus objetivos de aprendizado",
- "subtitle": "Para quê você quer usar o inglês? Selecione tudo que se aplica.",
+ "subtitle": "Idioma: {language}. Para que você quer usá-lo? Selecione todos os objetivos que se aplicam.",
"skip": "Pular",
"travel": "Viagens e turismo",
"work": "Trabalho e carreira",
@@ -1486,7 +1486,7 @@
},
"whatsNew": {
"title": "Novidades",
- "version": "v1.9.15",
+ "version": "v1.9.20",
"cta": "Entendido",
"entry1": {
"label": "Planos mais equilibrados",
@@ -1691,4 +1691,4 @@
"title": "Em manutenção",
"description": "Esta seção está temporariamente indisponível para manutenção. Volte mais tarde."
}
-}
+}
\ No newline at end of file
diff --git a/messages/ro.json b/messages/ro.json
index de8b09251..3b66a1f66 100644
--- a/messages/ro.json
+++ b/messages/ro.json
@@ -1295,7 +1295,7 @@
"saveFailed": "Salvarea preferințelor a eșuat",
"goals": {
"title": "Obiectivele tale de învățare",
- "subtitle": "Pentru ce vrei să folosești engleza? Selectează tot ce ți se potrivește.",
+ "subtitle": "Limbă: {language}. Pentru ce vrei să o folosești? Selectează toate obiectivele care ți se potrivesc.",
"skip": "Sari",
"travel": "Călătorii și turism",
"work": "Muncă și carieră",
@@ -1486,7 +1486,7 @@
},
"whatsNew": {
"title": "Noutăți",
- "version": "v1.9.15",
+ "version": "v1.9.20",
"cta": "Am înțeles",
"entry1": {
"label": "Planuri mai echilibrate",
@@ -1691,4 +1691,4 @@
"title": "În mentenanță",
"description": "Această secțiune este temporar indisponibilă pentru lucrări de întreținere. Încearcă din nou mai târziu."
}
-}
+}
\ No newline at end of file
diff --git a/messages/ru.json b/messages/ru.json
index d590c07ed..07ca35a14 100644
--- a/messages/ru.json
+++ b/messages/ru.json
@@ -1295,7 +1295,7 @@
"saveFailed": "Не удалось сохранить настройки",
"goals": {
"title": "Твои цели обучения",
- "subtitle": "Для чего ты хочешь использовать английский? Выбери всё, что подходит.",
+ "subtitle": "Язык: {language}. Для чего ты хочешь его использовать? Выбери все подходящие цели.",
"skip": "Пропустить",
"travel": "Путешествия и туризм",
"work": "Работа и карьера",
@@ -1486,7 +1486,7 @@
},
"whatsNew": {
"title": "Что нового",
- "version": "v1.9.15",
+ "version": "v1.9.20",
"cta": "Понятно",
"entry1": {
"label": "Более сбалансированные планы",
@@ -1691,4 +1691,4 @@
"title": "Техническое обслуживание",
"description": "Этот раздел временно недоступен в связи с техническим обслуживанием. Пожалуйста, зайдите позже."
}
-}
+}
\ No newline at end of file
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..2085b0f2a 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
@@ -108,6 +116,11 @@ Client state is split across auth, config, freemium, language, loading, progress
The loading store uses a request counter and completion state; API calls through `apiFetch` participate
automatically.
+The shared config store caches a successful `/api/config` response. Network, HTTP, and JSON parsing
+failures preserve the current values without marking the configuration as loaded, allowing the next
+`load()` call to retry. Public registration remains closed by default until configuration enables it;
+invitation forms remain available independently of configuration loading.
+
Theme supports system, dark, and light modes and is applied before first paint from persisted
preference.
@@ -185,12 +198,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.
diff --git a/specs/target-language.instructions.md b/specs/target-language.instructions.md
index 33393e745..f17deb08e 100644
--- a/specs/target-language.instructions.md
+++ b/specs/target-language.instructions.md
@@ -74,6 +74,13 @@ The current frontend registration form does not send target language. Onboarding
6. Creates or activates the initial `UserLanguage`.
7. Navigates to Dashboard; assessment remains a separate next action.
+The learning-goals subtitle identifies the selected language using the localized `targetLanguages`
+ISO 639-1 entry rather than the regional selector label. The name appears as a standalone language
+label, so translations do not require language-specific articles or inflections. Unrecognized
+selection codes use the language of `DEFAULT_TARGET_LANGUAGE` (`en-GB`) for display, including while
+the available-language request is pending or has failed. This display fallback does not change the
+selected code or replace backend validation.
+
## Compatibility field
`users.target_language` remains part of registration and profile responses for defaults and older
diff --git a/specs/version.md b/specs/version.md
index 9e9151997..040b7dc23 100644
--- a/specs/version.md
+++ b/specs/version.md
@@ -1,6 +1,6 @@
# Version
-**1.9.15**
+**1.9.20**
This is the canonical current development version. Its publication state and date belong to
`CHANGELOG.md`; the canonical value may therefore correspond to a changelog section marked