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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AGENTS.md — FreeLingo

**Current version: 1.9.15**
**Current version: 1.9.20**

## Project

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
![Next.js](https://img.shields.io/badge/next.js-16-black?style=flat-square)
![Python](https://img.shields.io/badge/python-3.14-blue?style=flat-square)
![Self-hosted](https://img.shields.io/badge/self--hosted-yes-orange?style=flat-square)
![Version](https://img.shields.io/badge/version-1.9.15-brightgreen?style=flat-square)
![Version](https://img.shields.io/badge/version-1.9.20-brightgreen?style=flat-square)

<p align="left">
<img src="assets/logo_large.png" alt="FreeLingo logo" />
Expand Down
2 changes: 1 addition & 1 deletion backend/app/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)

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"
2 changes: 2 additions & 0 deletions backend/tests/test_flashcards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
</div>
</div>
<p className="text-fl-label text-fl-muted-4 font-code mb-2 tracking-wider">
v1.9.15
v1.9.20
</p>
<button
onClick={() => setContactOpen(true)}
Expand Down Expand Up @@ -573,7 +573,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
</p>
)}
<p className="text-fl-label text-fl-muted-4 font-code mb-2 tracking-wider">
v1.9.15
v1.9.20
</p>
<button
onClick={() => {
Expand Down
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
13 changes: 11 additions & 2 deletions frontend/src/app/(auth)/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import { useAuthStore, isSubscribed, isFreemiumTrialActive } from '@/store/auth'
import { useConfigStore } from '@/store/config'
import { useLanguageStore } from '@/store/language'
import TargetLanguageSelector from '@/components/TargetLanguageSelector'
import { DEFAULT_TARGET_LANGUAGE } from '@/lib/target-languages'
import {
DEFAULT_TARGET_LANGUAGE,
getLanguageByCode,
} from '@/lib/target-languages'

const LEARNING_GOALS = [
'travel',
Expand All @@ -34,6 +37,7 @@ function getSelectedPlan(plan: string | null): BillingInterval | null {
export default function OnboardingPage() {
const t = useTranslations('onboarding')
const tCommon = useTranslations('common')
const tLang = useTranslations('targetLanguages')
const router = useRouter()
const searchParams = useSearchParams()
const setUser = useAuthStore((s) => s.setUser)
Expand Down Expand Up @@ -64,6 +68,11 @@ export default function OnboardingPage() {
useState<BillingInterval | null>(null)
const [checkoutError, setCheckoutError] = useState('')

const targetLanguageName = tLang(
getLanguageByCode(targetLanguage)?.iso639 ??
DEFAULT_TARGET_LANGUAGE.split('-')[0]
)

useEffect(() => {
loadConfig()
fetchLanguages().then(() => {
Expand Down Expand Up @@ -266,7 +275,7 @@ export default function OnboardingPage() {
{step === 2 && (
<div className="space-y-5">
<p className="text-fl-fg font-mono text-sm">
{t('goals.subtitle')}
{t('goals.subtitle', { language: targetLanguageName })}
</p>
<div className="grid grid-cols-2 gap-2">
{LEARNING_GOALS.map((goal) => {
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>
)
}
Loading
Loading