From b1525c703450e652cd8cb2a692215175cf5428e7 Mon Sep 17 00:00:00 2001 From: McAtk <16798627+atk0309@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:48:00 +0100 Subject: [PATCH] fix: respect registration availability in public signup UI --- CHANGELOG.md | 6 + backend/app/routers/config.py | 1 + backend/tests/test_auth.py | 61 ++++ frontend/src/app/(auth)/login/page.tsx | 30 +- frontend/src/app/(auth)/register/page.tsx | 74 ++++- frontend/src/app/(legal)/privacy/page.tsx | 23 +- frontend/src/app/(legal)/terms/page.tsx | 23 +- frontend/src/app/page.tsx | 23 +- .../src/components/billing/PricingSection.tsx | 17 +- frontend/src/store/config.ts | 5 +- frontend/tests/app/landing.test.tsx | 119 ++++++- frontend/tests/app/registration.test.tsx | 308 ++++++++++++++++++ .../tests/components/BillingPaywall.test.tsx | 72 ++-- frontend/tests/store/config.test.ts | 29 ++ specs/api-endpoints.instructions.md | 2 +- specs/architecture-frontend.instructions.md | 11 +- specs/platform.instructions.md | 26 +- 17 files changed, 740 insertions(+), 90 deletions(-) create mode 100644 frontend/tests/app/registration.test.tsx 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
+ {tCommon('tagline')}
+
+ {t('registrationClosed')}
+
+ FreeLingo
+
+