Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/app/routers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
61 changes: 61 additions & 0 deletions backend/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
30 changes: 20 additions & 10 deletions frontend/src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -9,13 +9,21 @@ 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')
const tCommon = useTranslations('common')
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('')
Expand Down Expand Up @@ -201,15 +209,17 @@ function LoginForm() {
</button>
</form>

<p className="text-fl-label text-fl-muted-2 mt-6 text-center font-mono tracking-wide">
{t('noAccount')}{' '}
<Link
href="/register"
className="text-fl-muted-1 hover:text-fl-fg transition-colors"
>
{t('register')}
</Link>
</p>
{allowRegistration && (
<p className="text-fl-label text-fl-muted-2 mt-6 text-center font-mono tracking-wide">
{t('noAccount')}{' '}
<Link
href="/register"
className="text-fl-muted-1 hover:text-fl-fg transition-colors"
>
{t('register')}
</Link>
</p>
)}
<p className="text-fl-label text-fl-muted-4 mt-3 text-center font-mono tracking-wide">
<Link
href="/forgot-password"
Expand Down
74 changes: 70 additions & 4 deletions frontend/src/app/(auth)/register/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
'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'
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',
Expand Down Expand Up @@ -358,14 +360,22 @@ function RegisterForm() {
>
{t('termsAccept')}{' '}
<a
href="/terms?from=register"
href={
invite
? `/terms?from=register&invite=${encodeURIComponent(invite)}`
: '/terms?from=register'
}
className="text-fl-muted-1 hover:text-fl-fg underline underline-offset-2 transition-colors"
>
{t('termsLink')}
</a>{' '}
{t('andWord')}{' '}
<a
href="/privacy?from=register"
href={
invite
? `/privacy?from=register&invite=${encodeURIComponent(invite)}`
: '/privacy?from=register'
}
className="text-fl-muted-1 hover:text-fl-fg underline underline-offset-2 transition-colors"
>
{t('privacyLink')}
Expand Down Expand Up @@ -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 <RegisterForm />
if (configLoading) return <PageLoading minHeight="min-h-screen" />

return (
<div className="bg-fl-bg flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="mb-10 flex flex-col items-center">
<Image
src="/logo.png"
alt="FreeLingo"
width={100}
height={100}
className="mb-4"
/>
<h1 className="text-fl-fg font-code text-xl font-bold tracking-widest uppercase">
FreeLingo
</h1>
<p className="text-fl-caption text-fl-muted-2 mt-1 font-mono tracking-widest uppercase">
{tCommon('tagline')}
</p>
</div>
<div className="border-fl-border bg-fl-surface border p-8">
<div className="border-fl-border mb-6 flex items-center gap-2 border-b pb-4">
<span className="text-fl-label text-fl-muted-2">●</span>
<span className="text-fl-muted-2 font-mono text-xs tracking-widest uppercase">
{t('title')}
</span>
</div>
<p className="border-fl-border bg-fl-bg text-fl-muted-1 mb-5 border px-4 py-4 text-center font-mono text-xs leading-relaxed">
{t('registrationClosed')}
</p>
<Link
href="/login"
className="bg-fl-accent text-fl-accent-fg hover:bg-fl-accent/90 block py-3 text-center font-mono text-sm font-bold tracking-widest uppercase transition-colors"
>
{t('login')}
</Link>
</div>
</div>
</div>
)
}

export default function RegisterPage() {
return (
<Suspense>
<RegisterForm />
<RegistrationGate />
</Suspense>
)
}
23 changes: 20 additions & 3 deletions frontend/src/app/(legal)/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
23 changes: 20 additions & 3 deletions frontend/src/app/(legal)/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Loading
Loading