diff --git a/AGENTS.md b/AGENTS.md index 2f92d4cea..eb5315d91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — FreeLingo -**Current version: 1.9.20** +**Current version: 1.9.25** ## Project diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a9756650..11ffa8195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,31 @@ 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). +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.25] - 2026-09-25 + +### Added + +- Turkish (`tr`), Swedish (`sv`), Danish (`da`), Finnish (`fi`), and Croatian (`hr`) interface and native-language options across registration, Settings, and administration, with translated UI, learning feedback, voice-session titles, and emails. These are not study languages. +- Dashboard announcements can use these five languages as source or translations alongside the original ten. + +### Changed + +- Dashboard announcements with ten through fourteen translations remain readable; saving requires all fifteen and completing a translation creates a new revision. The editor loads the chosen source language's text before translating. +- What's New presents the five interface languages (Croatian, Danish, Finnish, Swedish, and Turkish) in each of the fifteen interface locales alongside a general improvements note. +- Interface text, resource counts, accessible controls, payment-recovery and error messages, administrator badges, review ratings, learning-language names, dates, numbers, and prices follow the selected interface language. Study help displays the user's native-language name, and voice-session dates follow native-language conventions. +- The review prompt waits for the existing-review lookup before showing the rating form. The Dutch, Polish, Romanian, and Russian FAQs explain the default public-registration setting. + +### Fixed + +- Saving the interface language synchronizes locale cookies and applies the selection when the displayed language differs, including when that selection was already saved in the profile. +- Initial locale detection preserves an existing supported interface-language cookie and ignores `Accept-Language` entries with zero or out-of-range quality weights. +- Post-assessment voice-trial prompts and Premium continuation messages are translated in French, Portuguese, German, Italian, Polish, Dutch, Romanian, and Russian. +- Voice transcription, tutor-response, speech-generation, and transport errors display localized messages instead of raw backend or WebSocket diagnostics. +- Croatian feedback comment counts use the correct plural forms, including counts such as 21 and 31. +- Password reset displays the current password requirements and validates the 10-25 character length consistently with the backend, with translated errors across all fifteen interface locales. +- Transactional emails declare the language of their rendered content in HTML, including the English fallback for unsupported locales. ## [1.9.20] - 2026-09-24 diff --git a/README.md b/README.md index 5617e636b..d38e86685 100644 --- a/README.md +++ b/README.md @@ -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.20-brightgreen?style=flat-square) +![Version](https://img.shields.io/badge/version-1.9.25-brightgreen?style=flat-square)

FreeLingo logo @@ -71,7 +71,7 @@ freelingo/ ├── data/ # Shared data files ├── docs/ # GitHub Pages landing site ├── frontend/ # Next.js (React) -├── messages/ # i18n translation files (de, en, es, fr, it, nl, pl, pt, ro, ru) +├── messages/ # i18n catalogs (da, de, en, es, fi, fr, hr, it, nl, pl, pt, ro, ru, sv, tr) ├── specs/ # Specification files ├── AGENTS.md # AI assistant instructions ├── CHANGELOG.md # Version history diff --git a/backend/app/routers/admin_dashboard_banner.py b/backend/app/routers/admin_dashboard_banner.py index 7fd64931e..bfd26399a 100644 --- a/backend/app/routers/admin_dashboard_banner.py +++ b/backend/app/routers/admin_dashboard_banner.py @@ -19,8 +19,8 @@ router = APIRouter(prefix="/api/admin/dashboard-banner", tags=["admin"]) -_TRANSLATION_PROMPT = """Translate this dashboard announcement faithfully into all ten requested locales. -Return en, es, fr, pt, de, it, ru, nl, pl, and ro. Preserve the original tone and meaning. +_TRANSLATION_PROMPT = """Translate this dashboard announcement faithfully into all fifteen requested locales. +Return en, es, fr, pt, de, it, ru, nl, pl, ro, tr, sv, da, fi, and hr. Preserve the original tone and meaning. Do not add claims, details, formatting, Markdown, or HTML. Every field must contain plain text only. For the source locale, copy each provided field exactly. @@ -31,7 +31,9 @@ """ -@router.get("", response_model=DashboardBannerAdminResponse | None) +@router.get( + "", response_model=DashboardBannerAdminResponse | None, response_model_exclude_unset=True +) @limiter.limit("60/minute") async def get_dashboard_banner( request: Request, diff --git a/backend/app/routers/lessons.py b/backend/app/routers/lessons.py index b646d17db..707c033a0 100644 --- a/backend/app/routers/lessons.py +++ b/backend/app/routers/lessons.py @@ -119,6 +119,41 @@ "good_pronunciation": "Pronuntie buna!", "target_phrase": "Fraza tinta a fost: {answer}", }, + "tr": { + "correct": "Doğru!", + "correct_answer": "Doğru cevap: {answer}", + "free_write_unavailable": "Yazılı cevap şu anda değerlendirilemedi.", + "good_pronunciation": "Telaffuzunuz iyi!", + "target_phrase": "Hedef ifade şuydu: {answer}", + }, + "sv": { + "correct": "Rätt!", + "correct_answer": "Rätt svar: {answer}", + "free_write_unavailable": "Det gick inte att bedöma det skriftliga svaret just nu.", + "good_pronunciation": "Bra uttal!", + "target_phrase": "Målfrasen var: {answer}", + }, + "da": { + "correct": "Rigtigt!", + "correct_answer": "Det rigtige svar er: {answer}", + "free_write_unavailable": "Det skriftlige svar kunne ikke vurderes lige nu.", + "good_pronunciation": "God udtale!", + "target_phrase": "Målfrasen var: {answer}", + }, + "fi": { + "correct": "Oikein!", + "correct_answer": "Oikea vastaus: {answer}", + "free_write_unavailable": "Kirjoitettua vastausta ei voitu arvioida juuri nyt.", + "good_pronunciation": "Hyvä ääntäminen!", + "target_phrase": "Tavoiteilmaus oli: {answer}", + }, + "hr": { + "correct": "Točno!", + "correct_answer": "Točan odgovor: {answer}", + "free_write_unavailable": "Pisani odgovor trenutačno nije bilo moguće ocijeniti.", + "good_pronunciation": "Dobar izgovor!", + "target_phrase": "Ciljani izraz bio je: {answer}", + }, } diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index 8852b78ca..f1fdd9135 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -8,21 +8,24 @@ from app.core.config import settings +# Native-language codes accepted when creating users through the admin API. +# These match the registration and profile options, not the study-language catalog. SUPPORTED_LANGUAGES = { - "en", - "es", - "fr", - "pt", - "de", - "it", - "zh", - "ja", - "ko", - "ar", - "ru", - "nl", - "pl", - "ro", + "da", # Danish + "de", # German + "en", # English + "es", # Spanish + "fi", # Finnish + "fr", # French + "hr", # Croatian + "it", # Italian + "nl", # Dutch + "pl", # Polish + "pt", # Portuguese + "ro", # Romanian + "ru", # Russian + "sv", # Swedish + "tr", # Turkish } diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 53e9f0168..3331d15e1 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -29,6 +29,11 @@ def validate_password_strength(v: str) -> str: "nl", "pl", "ro", + "tr", + "sv", + "da", + "fi", + "hr", } SUPPORTED_TARGET_LANGUAGES: set[str] = { @@ -65,6 +70,11 @@ def get_available_languages() -> list[str]: "nl", "ro", "ru", + "tr", + "sv", + "da", + "fi", + "hr", } VALID_LEARNING_GOALS: set[str] = { diff --git a/backend/app/schemas/dashboard_banner.py b/backend/app/schemas/dashboard_banner.py index 53691ad92..f7723ed04 100644 --- a/backend/app/schemas/dashboard_banner.py +++ b/backend/app/schemas/dashboard_banner.py @@ -5,7 +5,9 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator -DashboardBannerLocale = Literal["en", "es", "fr", "pt", "de", "it", "ru", "nl", "pl", "ro"] +DashboardBannerLocale = Literal[ + "en", "es", "fr", "pt", "de", "it", "ru", "nl", "pl", "ro", "tr", "sv", "da", "fi", "hr" +] class DashboardBannerTranslation(BaseModel): @@ -24,7 +26,7 @@ def strip_nonblank_text(cls, value: str) -> str: return value -class DashboardBannerTranslations(BaseModel): +class DashboardBannerStoredTranslations(BaseModel): model_config = ConfigDict(extra="forbid") en: DashboardBannerTranslation @@ -37,6 +39,19 @@ class DashboardBannerTranslations(BaseModel): nl: DashboardBannerTranslation pl: DashboardBannerTranslation ro: DashboardBannerTranslation + tr: DashboardBannerTranslation | None = None + sv: DashboardBannerTranslation | None = None + da: DashboardBannerTranslation | None = None + fi: DashboardBannerTranslation | None = None + hr: DashboardBannerTranslation | None = None + + +class DashboardBannerTranslations(DashboardBannerStoredTranslations): + tr: DashboardBannerTranslation + sv: DashboardBannerTranslation + da: DashboardBannerTranslation + fi: DashboardBannerTranslation + hr: DashboardBannerTranslation class DashboardBannerTranslateRequest(DashboardBannerTranslation): @@ -57,7 +72,7 @@ class DashboardBannerUpdate(BaseModel): class DashboardBannerPublicResponse(BaseModel): revision: int - translations: DashboardBannerTranslations + translations: DashboardBannerStoredTranslations class DashboardBannerAdminResponse(DashboardBannerPublicResponse): diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 8c5802095..05092b8c8 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -122,6 +122,46 @@ def _get_mail_config() -> ConnectionConfig: "link_fallback": "\u0415\u0441\u043b\u0438 \u043a\u043d\u043e\u043f\u043a\u0430 \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442, \u0441\u043a\u043e\u043f\u0438\u0440\u0443\u0439\u0442\u0435 \u0438 \u0432\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u044d\u0442\u0443 \u0441\u0441\u044b\u043b\u043a\u0443 \u0432 \u0431\u0440\u0430\u0443\u0437\u0435\u0440:", "footer": "\u0415\u0441\u043b\u0438 \u0432\u044b \u043d\u0435 \u0441\u043e\u0437\u0434\u0430\u0432\u0430\u043b\u0438 \u044d\u0442\u043e\u0442 \u0430\u043a\u043a\u0430\u0443\u043d\u0442, \u043f\u0440\u043e\u0441\u0442\u043e \u0438\u0433\u043d\u043e\u0440\u0438\u0440\u0443\u0439\u0442\u0435 \u044d\u0442\u043e \u043f\u0438\u0441\u044c\u043c\u043e.", }, + "tr": { + "greeting": "Merhaba {name},", + "subject": "FreeLingo hesab\u0131n\u0131z\u0131 do\u011frulay\u0131n", + "body": "FreeLingo hesab\u0131n\u0131z\u0131 olu\u015fturdu\u011funuz i\u00e7in te\u015fekk\u00fcr ederiz.
L\u00fctfen a\u015fa\u011f\u0131daki d\u00fc\u011fmeye t\u0131klayarak e-posta adresinizi do\u011frulay\u0131n. Bu ba\u011flant\u0131 24 saat ge\u00e7erlidir.", + "button": "Hesab\u0131m\u0131 do\u011frula", + "link_fallback": "D\u00fc\u011fme \u00e7al\u0131\u015fmazsa bu ba\u011flant\u0131y\u0131 kopyalay\u0131p taray\u0131c\u0131n\u0131za yap\u0131\u015ft\u0131r\u0131n:", + "footer": "Bu hesab\u0131 siz olu\u015fturmad\u0131ysan\u0131z bu e-postay\u0131 yok sayabilirsiniz.", + }, + "sv": { + "greeting": "Hej {name},", + "subject": "Bekräfta ditt FreeLingo-konto", + "body": "Tack för att du skapade ett FreeLingo-konto.
Bekräfta din e-postadress genom att klicka på knappen nedan. Länken är giltig i 24 timmar.", + "button": "Bekräfta mitt konto", + "link_fallback": "Om knappen inte fungerar kan du kopiera länken och klistra in den i din webbläsare:", + "footer": "Om du inte har skapat kontot kan du ignorera det här e-postmeddelandet.", + }, + "da": { + "greeting": "Hej {name},", + "subject": "Bekræft din FreeLingo-konto", + "body": "Tak, fordi du oprettede en FreeLingo-konto.
Bekræft din e-mailadresse ved at klikke på knappen nedenfor. Linket er gyldigt i 24 timer.", + "button": "Bekræft min konto", + "link_fallback": "Hvis knappen ikke virker, kan du kopiere linket og indsætte det i din browser:", + "footer": "Hvis du ikke har oprettet kontoen, kan du ignorere denne e-mail.", + }, + "fi": { + "greeting": "Hei {name},", + "subject": "Vahvista FreeLingo-tilisi", + "body": "Kiitos, että loit FreeLingo-tilin.
Vahvista sähköpostiosoitteesi napsauttamalla alla olevaa painiketta. Linkki on voimassa 24 tuntia.", + "button": "Vahvista tilini", + "link_fallback": "Jos painike ei toimi, kopioi tämä linkki ja liitä se selaimeesi:", + "footer": "Jos et luonut tätä tiliä, voit jättää tämän viestin huomiotta.", + }, + "hr": { + "greeting": "Pozdrav {name},", + "subject": "Potvrdi svoj FreeLingo račun", + "body": "Hvala što si stvorio/la FreeLingo račun.
Potvrdi adresu e-pošte klikom na gumb u nastavku. Poveznica vrijedi 24 sata.", + "button": "Potvrdi moj račun", + "link_fallback": "Ako gumb ne radi, kopiraj ovu poveznicu i zalijepi je u preglednik:", + "footer": "Ako nisi stvorio/la ovaj račun, možeš zanemariti ovu poruku.", + }, } _RESET_I18N: dict[str, dict[str, str]] = { @@ -205,6 +245,46 @@ def _get_mail_config() -> ConnectionConfig: "link_fallback": "\u0415\u0441\u043b\u0438 \u043a\u043d\u043e\u043f\u043a\u0430 \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442, \u0441\u043a\u043e\u043f\u0438\u0440\u0443\u0439\u0442\u0435 \u0438 \u0432\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u044d\u0442\u0443 \u0441\u0441\u044b\u043b\u043a\u0443 \u0432 \u0431\u0440\u0430\u0443\u0437\u0435\u0440:", "footer": "\u0415\u0441\u043b\u0438 \u0432\u044b \u043d\u0435 \u0437\u0430\u043f\u0440\u0430\u0448\u0438\u0432\u0430\u043b\u0438 \u0441\u0431\u0440\u043e\u0441, \u043f\u0440\u043e\u0441\u0442\u043e \u0438\u0433\u043d\u043e\u0440\u0438\u0440\u0443\u0439\u0442\u0435 \u044d\u0442\u043e \u043f\u0438\u0441\u044c\u043c\u043e.", }, + "tr": { + "greeting": "Merhaba {name},", + "subject": "FreeLingo parolan\u0131z\u0131 s\u0131f\u0131rlay\u0131n", + "body": "FreeLingo hesab\u0131n\u0131z\u0131n parolas\u0131n\u0131 s\u0131f\u0131rlama iste\u011fi ald\u0131k.
Yeni bir parola olu\u015fturmak i\u00e7in a\u015fa\u011f\u0131daki d\u00fc\u011fmeye t\u0131klay\u0131n. Bu ba\u011flant\u0131 1 saat ge\u00e7erlidir.", + "button": "Parolam\u0131 s\u0131f\u0131rla", + "link_fallback": "D\u00fc\u011fme \u00e7al\u0131\u015fmazsa bu ba\u011flant\u0131y\u0131 kopyalay\u0131p taray\u0131c\u0131n\u0131za yap\u0131\u015ft\u0131r\u0131n:", + "footer": "Parola s\u0131f\u0131rlama iste\u011finde bulunmad\u0131ysan\u0131z bu e-postay\u0131 g\u00fcvenle yok sayabilirsiniz.", + }, + "sv": { + "greeting": "Hej {name},", + "subject": "Återställ ditt FreeLingo-lösenord", + "body": "Vi har fått en begäran om att återställa lösenordet för ditt FreeLingo-konto.
Klicka på knappen nedan för att skapa ett nytt lösenord. Länken är giltig i 1 timme.", + "button": "Återställ mitt lösenord", + "link_fallback": "Om knappen inte fungerar kan du kopiera länken och klistra in den i din webbläsare:", + "footer": "Om du inte har begärt en lösenordsåterställning kan du ignorera det här e-postmeddelandet.", + }, + "da": { + "greeting": "Hej {name},", + "subject": "Nulstil din FreeLingo-adgangskode", + "body": "Vi har modtaget en anmodning om at nulstille adgangskoden til din FreeLingo-konto.
Klik på knappen nedenfor for at oprette en ny adgangskode. Linket er gyldigt i 1 time.", + "button": "Nulstil min adgangskode", + "link_fallback": "Hvis knappen ikke virker, kan du kopiere linket og indsætte det i din browser:", + "footer": "Hvis du ikke har bedt om at få nulstillet adgangskoden, kan du ignorere denne e-mail.", + }, + "fi": { + "greeting": "Hei {name},", + "subject": "Palauta FreeLingo-salasanasi", + "body": "Saimme pyynnön palauttaa FreeLingo-tilisi salasana.
Luo uusi salasana napsauttamalla alla olevaa painiketta. Linkki on voimassa 1 tunnin.", + "button": "Palauta salasanani", + "link_fallback": "Jos painike ei toimi, kopioi tämä linkki ja liitä se selaimeesi:", + "footer": "Jos et pyytänyt salasanan palautusta, voit jättää tämän viestin huomiotta.", + }, + "hr": { + "greeting": "Pozdrav {name},", + "subject": "Postavi novu FreeLingo lozinku", + "body": "Primili smo zahtjev za postavljanje nove lozinke za tvoj FreeLingo račun.
Klikni na gumb u nastavku kako bi postavio/la novu lozinku. Poveznica vrijedi 1 sat.", + "button": "Postavi novu lozinku", + "link_fallback": "Ako gumb ne radi, kopiraj ovu poveznicu i zalijepi je u preglednik:", + "footer": "Ako nisi zatražio/la novu lozinku, možeš zanemariti ovu poruku.", + }, } _WELCOME_I18N: dict[str, dict[str, str]] = { @@ -308,6 +388,56 @@ def _get_mail_config() -> ConnectionConfig: "button": "\u041f\u0435\u0440\u0435\u0439\u0442\u0438 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u044c \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f", "footer": "\u0423\u0441\u043f\u0435\u0445\u043e\u0432 \u0432 \u043e\u0431\u0443\u0447\u0435\u043d\u0438\u0438!", }, + "tr": { + "subject": "FreeLingo'ya ho\u015f geldiniz!", + "greeting": "Merhaba {name},", + "body": "Hesab\u0131n\u0131z haz\u0131r. Ba\u015flamak i\u00e7in \u015funlar\u0131 yapabilirsiniz:", + "step1": "Seviye Tespitine girin \u2014 mevcut seviyenizi (A1\u2013C2), g\u00fc\u00e7l\u00fc ve zay\u0131f y\u00f6nlerinizi belirler.", + "step2": "\u00c7al\u0131\u015fma Plan\u0131n\u0131z\u0131 olu\u015fturun \u2014 seviyenize ve hedeflerinize g\u00f6re haz\u0131rlanm\u0131\u015f haftal\u0131k bir program.", + "step3": "\u0130lk g\u00fcnl\u00fck dersinizi tamamlay\u0131n ve serinizi olu\u015fturmaya ba\u015flay\u0131n.", + "button": "Panoma git", + "footer": "\u0130yi \u00e7al\u0131\u015fmalar!", + }, + "sv": { + "subject": "Välkommen till FreeLingo!", + "greeting": "Hej {name},", + "body": "Ditt konto är redo. Så här kommer du igång:", + "step1": "Gör nivåbedömningen – den visar din nuvarande nivå (A1–C2) samt dina styrkor och svagheter.", + "step2": "Skapa din studieplan – en plan vecka för vecka anpassad till din nivå och dina mål.", + "step3": "Slutför din första dagliga lektion och börja bygga din svit.", + "button": "Gå till min översikt", + "footer": "Lycka till med studierna!", + }, + "da": { + "subject": "Velkommen til FreeLingo!", + "greeting": "Hej {name},", + "body": "Din konto er klar. Sådan kommer du i gang:", + "step1": "Tag niveauvurderingen – den viser dit aktuelle niveau (A1–C2) samt dine styrker og svagheder.", + "step2": "Opret din læringsplan – en ugeplan tilpasset dit niveau og dine mål.", + "step3": "Gennemfør din første daglige lektion, og begynd at opbygge din stime.", + "button": "Gå til min oversigt", + "footer": "God fornøjelse med læringen!", + }, + "fi": { + "subject": "Tervetuloa FreeLingoon!", + "greeting": "Hei {name},", + "body": "Tilisi on valmis. Näin pääset alkuun:", + "step1": "Tee tasoarvio – se selvittää nykyisen tasosi (A1–C2) sekä vahvuutesi ja kehittämiskohteesi.", + "step2": "Luo opintosuunnitelmasi – tasosi ja tavoitteidesi mukainen viikkosuunnitelma.", + "step3": "Suorita ensimmäinen päivittäinen oppituntisi ja aloita putkesi.", + "button": "Siirry koontinäyttöön", + "footer": "Iloisia opiskeluhetkiä!", + }, + "hr": { + "subject": "Dobrodošli u FreeLingo!", + "greeting": "Pozdrav {name},", + "body": "Tvoj račun je spreman. Evo kako možeš početi:", + "step1": "Riješi procjenu razine – ona utvrđuje tvoju trenutačnu razinu (A1–C2) te prednosti i slabosti.", + "step2": "Stvori plan učenja – tjedni program prilagođen tvojoj razini i ciljevima.", + "step3": "Dovrši svoju prvu dnevnu lekciju i počni graditi niz.", + "button": "Idi na nadzornu ploču", + "footer": "Sretno s učenjem!", + }, } @@ -372,6 +502,36 @@ def _get_mail_config() -> ConnectionConfig: "body": "\u0412\u0430\u0448 \u0430\u043a\u043a\u0430\u0443\u043d\u0442 FreeLingo \u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0443\u0434\u0430\u043b\u0451\u043d. \u0412\u0441\u0435 \u0432\u0430\u0448\u0438 \u0434\u0430\u043d\u043d\u044b\u0435 \u0431\u044b\u043b\u0438 \u043d\u0430\u0432\u0441\u0435\u0433\u0434\u0430 \u0443\u0434\u0430\u043b\u0435\u043d\u044b \u0441 \u043d\u0430\u0448\u0438\u0445 \u0441\u0435\u0440\u0432\u0435\u0440\u043e\u0432.", "footer": "\u0415\u0441\u043b\u0438 \u0432\u044b \u043d\u0435 \u0437\u0430\u043f\u0440\u0430\u0448\u0438\u0432\u0430\u043b\u0438 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435, \u043d\u0435\u043c\u0435\u0434\u043b\u0435\u043d\u043d\u043e \u0441\u0432\u044f\u0436\u0438\u0442\u0435\u0441\u044c \u0441 \u043d\u0430\u043c\u0438.", }, + "tr": { + "subject": "FreeLingo hesab\u0131n\u0131z silindi", + "greeting": "Merhaba {name},", + "body": "FreeLingo hesab\u0131n\u0131z ba\u015far\u0131yla silindi. T\u00fcm verileriniz sunucular\u0131m\u0131zdan kal\u0131c\u0131 olarak kald\u0131r\u0131ld\u0131.", + "footer": "Bu silme i\u015flemini siz istemediyseniz l\u00fctfen hemen bizimle ileti\u015fime ge\u00e7in.", + }, + "sv": { + "subject": "Ditt FreeLingo-konto har raderats", + "greeting": "Hej {name},", + "body": "Ditt FreeLingo-konto har raderats. Alla dina uppgifter har tagits bort permanent från våra servrar.", + "footer": "Om du inte har begärt att kontot ska raderas ska du kontakta oss omedelbart.", + }, + "da": { + "subject": "Din FreeLingo-konto er blevet slettet", + "greeting": "Hej {name},", + "body": "Din FreeLingo-konto er blevet slettet. Alle dine oplysninger er fjernet permanent fra vores servere.", + "footer": "Hvis du ikke har bedt om sletningen, skal du kontakte os med det samme.", + }, + "fi": { + "subject": "FreeLingo-tilisi on poistettu", + "greeting": "Hei {name},", + "body": "FreeLingo-tilisi on poistettu. Kaikki tietosi on poistettu pysyvästi palvelimiltamme.", + "footer": "Jos et pyytänyt tilin poistamista, ota meihin välittömästi yhteyttä.", + }, + "hr": { + "subject": "Tvoj FreeLingo račun je izbrisan", + "greeting": "Pozdrav {name},", + "body": "Tvoj FreeLingo račun uspješno je izbrisan. Svi tvoji podaci trajno su uklonjeni s naših poslužitelja.", + "footer": "Ako nisi zatražio/la brisanje, odmah nam se javi.", + }, } _CONTACT_I18N: dict[str, dict[str, str]] = { @@ -465,6 +625,51 @@ def _get_mail_config() -> ConnectionConfig: "message_label": "Сообщение", "footer": "Отправлено через контактную форму FreeLingo", }, + "tr": { + "email_title": "İletişim formu mesajı", + "subject_prefix": "[FreeLingo İletişim]", + "logo": "FreeLingo: İletişim Formu", + "from_label": "Gönderen", + "subject_label": "Konu", + "message_label": "Mesaj", + "footer": "FreeLingo iletişim formu aracılığıyla gönderildi", + }, + "sv": { + "email_title": "Meddelande från kontaktformuläret", + "subject_prefix": "[FreeLingo Kontakt]", + "logo": "FreeLingo: Kontaktformulär", + "from_label": "Från", + "subject_label": "Ämne", + "message_label": "Meddelande", + "footer": "Skickat via FreeLingos kontaktformulär", + }, + "da": { + "email_title": "Besked fra kontaktformularen", + "subject_prefix": "[FreeLingo Kontakt]", + "logo": "FreeLingo: Kontaktformular", + "from_label": "Fra", + "subject_label": "Emne", + "message_label": "Besked", + "footer": "Sendt via FreeLingos kontaktformular", + }, + "fi": { + "email_title": "Yhteydenottolomakkeen viesti", + "subject_prefix": "[FreeLingo Yhteydenotto]", + "logo": "FreeLingo: Yhteydenottolomake", + "from_label": "Lähettäjä", + "subject_label": "Aihe", + "message_label": "Viesti", + "footer": "Lähetetty FreeLingon yhteydenottolomakkeella", + }, + "hr": { + "email_title": "Poruka iz kontaktnog obrasca", + "subject_prefix": "[FreeLingo Kontakt]", + "logo": "FreeLingo: Kontaktni obrazac", + "from_label": "Od", + "subject_label": "Predmet", + "message_label": "Poruka", + "footer": "Poslano putem FreeLingova kontaktnog obrasca", + }, } _FEEDBACK_I18N: dict[str, dict[str, str]] = { @@ -598,6 +803,71 @@ def _get_mail_config() -> ConnectionConfig: "cta": "Открыть в панели администратора", "footer": "Доска отзывов FreeLingo", }, + "tr": { + "email_title": "Yeni geri bildirim gönderildi", + "logo": "FreeLingo: Yeni Geri Bildirim", + "feature_label": "Özellik isteği", + "bug_label": "Hata bildirimi", + "feature_subject_prefix": "[Özellik İsteği]", + "bug_subject_prefix": "[Hata Bildirimi]", + "author_label": "Gönderen", + "title_label": "Başlık", + "description_label": "Açıklama", + "cta": "Yönetim panelinde görüntüle", + "footer": "FreeLingo geri bildirim panosu", + }, + "sv": { + "email_title": "Ny feedback har skickats in", + "logo": "FreeLingo: Ny feedback", + "feature_label": "Funktionsförslag", + "bug_label": "Felrapport", + "feature_subject_prefix": "[Funktionsförslag]", + "bug_subject_prefix": "[Felrapport]", + "author_label": "Skickat av", + "title_label": "Titel", + "description_label": "Beskrivning", + "cta": "Visa i administrationspanelen", + "footer": "FreeLingos feedbacktavla", + }, + "da": { + "email_title": "Ny feedback er indsendt", + "logo": "FreeLingo: Ny feedback", + "feature_label": "Funktionsforslag", + "bug_label": "Fejlrapport", + "feature_subject_prefix": "[Funktionsforslag]", + "bug_subject_prefix": "[Fejlrapport]", + "author_label": "Indsendt af", + "title_label": "Titel", + "description_label": "Beskrivelse", + "cta": "Se i administrationspanelet", + "footer": "FreeLingos feedbacktavle", + }, + "fi": { + "email_title": "Uusi palaute lähetetty", + "logo": "FreeLingo: Uusi palaute", + "feature_label": "Ominaisuusehdotus", + "bug_label": "Virheraportti", + "feature_subject_prefix": "[Ominaisuusehdotus]", + "bug_subject_prefix": "[Virheraportti]", + "author_label": "Lähettäjä", + "title_label": "Otsikko", + "description_label": "Kuvaus", + "cta": "Näytä ylläpidossa", + "footer": "FreeLingon palautetaulu", + }, + "hr": { + "email_title": "Poslane su nove povratne informacije", + "logo": "FreeLingo: Nove povratne informacije", + "feature_label": "Prijedlog značajke", + "bug_label": "Prijava pogreške", + "feature_subject_prefix": "[Prijedlog značajke]", + "bug_subject_prefix": "[Prijava pogreške]", + "author_label": "Poslao/la", + "title_label": "Naslov", + "description_label": "Opis", + "cta": "Pogledaj u administraciji", + "footer": "FreeLingova ploča za povratne informacije", + }, } _REVIEW_I18N: dict[str, dict[str, str]] = { @@ -721,13 +991,74 @@ def _get_mail_config() -> ConnectionConfig: "cta": "Открыть в панели администратора", "footer": "Модерация отзывов FreeLingo", }, + "tr": { + "email_title": "Yeni yorum gönderildi", + "subject_prefix": "[Yeni Yorum]", + "logo": "FreeLingo: Yeni Yorum", + "author_label": "Gönderen", + "rating_label": "Puan", + "language_label": "Öğrenilen dil", + "comment_label": "Yorum", + "empty_comment": "Yorum yazılmadı.", + "cta": "Yönetim panelinde görüntüle", + "footer": "FreeLingo yorum moderasyonu", + }, + "sv": { + "email_title": "Nytt omdöme har skickats in", + "subject_prefix": "[Nytt omdöme]", + "logo": "FreeLingo: Nytt omdöme", + "author_label": "Skickat av", + "rating_label": "Betyg", + "language_label": "Språk som lärs in", + "comment_label": "Kommentar", + "empty_comment": "Ingen kommentar lämnades.", + "cta": "Visa i administrationspanelen", + "footer": "Moderering av FreeLingo-omdömen", + }, + "da": { + "email_title": "Ny anmeldelse er indsendt", + "subject_prefix": "[Ny anmeldelse]", + "logo": "FreeLingo: Ny anmeldelse", + "author_label": "Indsendt af", + "rating_label": "Bedømmelse", + "language_label": "Sprog, der læres", + "comment_label": "Kommentar", + "empty_comment": "Ingen kommentar tilføjet.", + "cta": "Se i administrationspanelet", + "footer": "Moderering af FreeLingo-anmeldelser", + }, + "fi": { + "email_title": "Uusi arvostelu lähetetty", + "subject_prefix": "[Uusi arvostelu]", + "logo": "FreeLingo: Uusi arvostelu", + "author_label": "Lähettäjä", + "rating_label": "Arvosana", + "language_label": "Opiskeltava kieli", + "comment_label": "Kommentti", + "empty_comment": "Kommenttia ei annettu.", + "cta": "Näytä ylläpidossa", + "footer": "FreeLingon arvostelujen moderointi", + }, + "hr": { + "email_title": "Poslana je nova recenzija", + "subject_prefix": "[Nova recenzija]", + "logo": "FreeLingo: Nova recenzija", + "author_label": "Poslao/la", + "rating_label": "Ocjena", + "language_label": "Jezik koji se uči", + "comment_label": "Komentar", + "empty_comment": "Nema komentara.", + "cta": "Pogledaj u administraciji", + "footer": "Moderiranje FreeLingo recenzija", + }, } -def _render_template(name: str, context: dict) -> str: +def _render_template(name: str, context: dict, locale: str = "en") -> str: """Render a plain HTML template, escaping interpolated values by default.""" path = _TEMPLATES_DIR / name html = path.read_text(encoding="utf-8") + context = {**context, "locale": locale if locale in _VERIFY_I18N else "en"} for key, value in context.items(): rendered = str(value) if isinstance(value, _SafeHtml) else escape(str(value), quote=True) html = html.replace(f"{{{{{key}}}}}", rendered) @@ -767,6 +1098,7 @@ async def send_verification_email( "url": url, "base_url": settings.APP_BASE_URL, }, + locale=locale, ) await _dispatch(strings["subject"], [to], html) @@ -790,6 +1122,7 @@ async def send_reset_password_email( "url": url, "base_url": settings.APP_BASE_URL, }, + locale=locale, ) await _dispatch(strings["subject"], [to], html) @@ -813,6 +1146,7 @@ async def send_welcome_email(to: str, display_name: str, locale: str = "en") -> "url": url, "base_url": settings.APP_BASE_URL, }, + locale=locale, ) await _dispatch(strings["subject"], [to], html) @@ -841,6 +1175,7 @@ async def send_contact_email( "footer": strings["footer"], "base_url": settings.APP_BASE_URL, }, + locale=locale, ) message = MessageSchema( subject=f"{strings['subject_prefix']} {subject}", @@ -870,6 +1205,7 @@ async def send_account_deleted_email(to: str, display_name: str, locale: str = " "footer": strings["footer"], "base_url": settings.APP_BASE_URL, }, + locale=locale, ) await _dispatch(strings["subject"], [to], html) @@ -914,6 +1250,7 @@ async def send_feedback_notification( "admin_url": admin_url, "base_url": settings.APP_BASE_URL, }, + locale=locale, ) subject_prefix = ( strings["feature_subject_prefix"] @@ -962,6 +1299,7 @@ async def send_review_notification( "footer": strings["footer"], "base_url": settings.APP_BASE_URL, }, + locale=locale, ) await _dispatch( f"{strings['subject_prefix']} {rating}/5 — {user_display_name}", diff --git a/backend/app/services/language_helpers.py b/backend/app/services/language_helpers.py index 37094d13c..daf0fc48e 100644 --- a/backend/app/services/language_helpers.py +++ b/backend/app/services/language_helpers.py @@ -135,6 +135,11 @@ "nl": "Spraaksessie", "ro": "Sesiune vocală", "ru": "Голосовая сессия", + "tr": "Sesli oturum", + "sv": "Röstsamtal", + "da": "Stemmesamtale", + "fi": "Äänikeskustelu", + "hr": "Glasovni razgovor", } _NATIVE_LANGUAGE_NAMES: dict[str, str] = { @@ -148,6 +153,11 @@ "nl": "Dutch", "ro": "Romanian", "ru": "Russian", + "tr": "Turkish", + "sv": "Swedish", + "da": "Danish", + "fi": "Finnish", + "hr": "Croatian", } _MONTH_NAMES: dict[str, list[str]] = { @@ -277,6 +287,76 @@ "ноября", "декабря", ], + "tr": [ + "Ocak", + "Şubat", + "Mart", + "Nisan", + "Mayıs", + "Haziran", + "Temmuz", + "Ağustos", + "Eylül", + "Ekim", + "Kasım", + "Aralık", + ], + "sv": [ + "januari", + "februari", + "mars", + "april", + "maj", + "juni", + "juli", + "augusti", + "september", + "oktober", + "november", + "december", + ], + "da": [ + "januar", + "februar", + "marts", + "april", + "maj", + "juni", + "juli", + "august", + "september", + "oktober", + "november", + "december", + ], + "fi": [ + "tammikuuta", + "helmikuuta", + "maaliskuuta", + "huhtikuuta", + "toukokuuta", + "kesäkuuta", + "heinäkuuta", + "elokuuta", + "syyskuuta", + "lokakuuta", + "marraskuuta", + "joulukuuta", + ], + "hr": [ + "siječnja", + "veljače", + "ožujka", + "travnja", + "svibnja", + "lipnja", + "srpnja", + "kolovoza", + "rujna", + "listopada", + "studenoga", + "prosinca", + ], } @@ -357,5 +437,9 @@ def voice_session_title(native_language: str) -> str: month_name = months[now.month - 1] if native_language in ("es", "pt"): return f"{label} — {now.day} de {month_name} de {now.year}" + if native_language in ("de", "da", "fi"): + return f"{label} — {now.day}. {month_name} {now.year}" + if native_language == "hr": + return f"{label} — {now.day}. {month_name} {now.year}." return f"{label} — {now.day} {month_name} {now.year}" return f"{label} — {now.strftime('%B %d, %Y')}" diff --git a/backend/app/templates/email/account_deleted.html b/backend/app/templates/email/account_deleted.html index 737668ce0..7e9ee6010 100644 --- a/backend/app/templates/email/account_deleted.html +++ b/backend/app/templates/email/account_deleted.html @@ -1,5 +1,5 @@ - + diff --git a/backend/app/templates/email/contact.html b/backend/app/templates/email/contact.html index a56fa5667..982ff4a27 100644 --- a/backend/app/templates/email/contact.html +++ b/backend/app/templates/email/contact.html @@ -1,5 +1,5 @@ - + diff --git a/backend/app/templates/email/feedback_submitted.html b/backend/app/templates/email/feedback_submitted.html index fa3430746..05cb6b2dc 100644 --- a/backend/app/templates/email/feedback_submitted.html +++ b/backend/app/templates/email/feedback_submitted.html @@ -1,5 +1,5 @@ - + diff --git a/backend/app/templates/email/reset_password.html b/backend/app/templates/email/reset_password.html index 5b4fbdcca..14817d4d5 100644 --- a/backend/app/templates/email/reset_password.html +++ b/backend/app/templates/email/reset_password.html @@ -1,5 +1,5 @@ - + diff --git a/backend/app/templates/email/review_submitted.html b/backend/app/templates/email/review_submitted.html index 35976b0de..0ce904374 100644 --- a/backend/app/templates/email/review_submitted.html +++ b/backend/app/templates/email/review_submitted.html @@ -1,5 +1,5 @@ - + diff --git a/backend/app/templates/email/verify_email.html b/backend/app/templates/email/verify_email.html index 683acad4b..40e232004 100644 --- a/backend/app/templates/email/verify_email.html +++ b/backend/app/templates/email/verify_email.html @@ -1,5 +1,5 @@ - + diff --git a/backend/app/templates/email/welcome.html b/backend/app/templates/email/welcome.html index 46d332b42..142eeff92 100644 --- a/backend/app/templates/email/welcome.html +++ b/backend/app/templates/email/welcome.html @@ -1,5 +1,5 @@ - + diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 911541ad7..c5dbd001f 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -647,6 +647,58 @@ async def test_patch_me_ui_locale_empty_string_clears(client, test_user): assert response.json()["ui_locale"] is None +@pytest.mark.asyncio +async def test_patch_me_accepts_swedish_ui_locale_and_native_language(client, test_user): + _, headers = test_user + response = await client.patch( + "/api/auth/me", + headers=headers, + json={"ui_locale": "sv", "native_language": "sv"}, + ) + assert response.status_code == 200 + assert response.json()["ui_locale"] == "sv" + assert response.json()["native_language"] == "sv" + + +@pytest.mark.asyncio +async def test_patch_me_accepts_danish_ui_locale_and_native_language(client, test_user): + _, headers = test_user + response = await client.patch( + "/api/auth/me", + headers=headers, + json={"ui_locale": "da", "native_language": "da"}, + ) + assert response.status_code == 200 + assert response.json()["ui_locale"] == "da" + assert response.json()["native_language"] == "da" + + +@pytest.mark.asyncio +async def test_patch_me_accepts_finnish_ui_locale_and_native_language(client, test_user): + _, headers = test_user + response = await client.patch( + "/api/auth/me", + headers=headers, + json={"ui_locale": "fi", "native_language": "fi"}, + ) + assert response.status_code == 200 + assert response.json()["ui_locale"] == "fi" + assert response.json()["native_language"] == "fi" + + +@pytest.mark.asyncio +async def test_patch_me_accepts_croatian_ui_locale_and_native_language(client, test_user): + _, headers = test_user + response = await client.patch( + "/api/auth/me", + headers=headers, + json={"ui_locale": "hr", "native_language": "hr"}, + ) + assert response.status_code == 200 + assert response.json()["ui_locale"] == "hr" + assert response.json()["native_language"] == "hr" + + @pytest.mark.asyncio async def test_register_unsupported_native_language(client): """Registration must reject native_language codes outside SUPPORTED_LANGUAGES.""" diff --git a/backend/tests/test_conversation.py b/backend/tests/test_conversation.py index 394c65f04..aeff9872c 100644 --- a/backend/tests/test_conversation.py +++ b/backend/tests/test_conversation.py @@ -547,13 +547,14 @@ def test_voice_session_title_portuguese() -> None: def test_voice_session_title_german() -> None: - """German uses 'Sprachsitzung' label and plain 'D Month YYYY' format.""" + """German uses an ordinal day before the month.""" from app.services.language_helpers import voice_session_title title = voice_session_title("de") assert title.startswith("Sprachsitzung — ") # No 'de' separators in German dates. date_part = title.split(" — ", 1)[1] + assert date_part.split(" ", 1)[0].endswith(".") assert " de " not in date_part @@ -577,11 +578,58 @@ def test_voice_session_title_russian() -> None: assert " de " not in date_part +def test_voice_session_title_turkish() -> None: + """Turkish uses its own label and plain 'D Month YYYY' format.""" + from app.services.language_helpers import voice_session_title + + title = voice_session_title("tr") + assert title.startswith("Sesli oturum — ") + date_part = title.split(" — ", 1)[1] + assert " de " not in date_part + + +def test_voice_session_title_swedish() -> None: + from app.services.language_helpers import voice_session_title + + title = voice_session_title("sv") + assert title.startswith("Röstsamtal — ") + assert " de " not in title + + +def test_voice_session_title_danish() -> None: + from app.services.language_helpers import voice_session_title + + title = voice_session_title("da") + assert title.startswith("Stemmesamtale — ") + date = title.split(" — ", 1)[1] + assert date.split(" ", 1)[0].endswith(".") + assert " de " not in date + + +def test_voice_session_title_finnish() -> None: + from app.services.language_helpers import voice_session_title + + title = voice_session_title("fi") + assert title.startswith("Äänikeskustelu — ") + assert title.split(" — ", 1)[1].split(" ", 1)[0].endswith(".") + assert "kuuta " in title + + +def test_voice_session_title_croatian() -> None: + from app.services.language_helpers import voice_session_title + + title = voice_session_title("hr") + assert title.startswith("Glasovni razgovor — ") + date = title.split(" — ", 1)[1] + assert date.split(" ", 1)[0].endswith(".") + assert date.endswith(".") + + def test_voice_session_title_all_supported_languages_produce_nonempty() -> None: """Every supported native language must produce a non-empty title.""" from app.services.language_helpers import voice_session_title - supported = ["es", "fr", "pt", "de", "it", "pl", "nl", "ro", "ru"] + supported = ["es", "fr", "pt", "de", "it", "pl", "nl", "ro", "ru", "tr", "sv", "da", "fi", "hr"] for lang in supported: title = voice_session_title(lang) assert title.strip(), f"Empty title for language '{lang}'" diff --git a/backend/tests/test_dashboard_banner.py b/backend/tests/test_dashboard_banner.py index d80905b75..3b2e6748b 100644 --- a/backend/tests/test_dashboard_banner.py +++ b/backend/tests/test_dashboard_banner.py @@ -2,10 +2,11 @@ import pytest +from app.models.dashboard_banner import DashboardBanner from app.schemas.dashboard_banner import DashboardBannerTranslationResponse from app.services.llm_adapter import LLMError -LOCALES = ("en", "es", "fr", "pt", "de", "it", "ru", "nl", "pl", "ro") +LOCALES = ("en", "es", "fr", "pt", "de", "it", "ru", "nl", "pl", "ro", "tr", "sv", "da", "fi", "hr") def banner_translations(label: str = "Notice") -> dict[str, dict[str, str]]: @@ -45,6 +46,177 @@ async def test_config_and_admin_banner_default_to_null(client, admin_user): assert admin_response.json() is None +@pytest.mark.asyncio +async def test_existing_ten_locale_banner_remains_readable_until_turkish_is_added( + client, admin_user, db_session +): + _, headers = admin_user + legacy_translations = banner_translations() + legacy_translations.pop("tr") + legacy_translations.pop("sv") + legacy_translations.pop("da") + legacy_translations.pop("fi") + legacy_translations.pop("hr") + db_session.add( + DashboardBanner( + id=1, + source_locale="en", + is_active=True, + translations=legacy_translations, + revision=4, + ) + ) + await db_session.commit() + + admin_response = await client.get("/api/admin/dashboard-banner", headers=headers) + public_response = await client.get("/api/config") + + assert admin_response.status_code == 200 + assert admin_response.json()["translations"] == legacy_translations + assert admin_response.json()["revision"] == 4 + assert public_response.json()["dashboard_banner"] == { + "revision": 4, + "translations": legacy_translations, + } + + missing_tr = await client.put( + "/api/admin/dashboard-banner", + headers=headers, + json=banner_update(translations=legacy_translations), + ) + assert missing_tr.status_code == 422 + + +@pytest.mark.asyncio +async def test_existing_eleven_locale_banner_remains_readable_until_swedish_is_added( + client, admin_user, db_session +): + _, headers = admin_user + legacy_translations = banner_translations() + legacy_translations.pop("sv") + legacy_translations.pop("da") + legacy_translations.pop("fi") + legacy_translations.pop("hr") + db_session.add( + DashboardBanner( + id=1, + source_locale="en", + is_active=True, + translations=legacy_translations, + revision=5, + ) + ) + await db_session.commit() + + response = await client.get("/api/admin/dashboard-banner", headers=headers) + assert response.status_code == 200 + assert response.json()["translations"] == legacy_translations + assert response.json()["revision"] == 5 + + missing_sv = await client.put( + "/api/admin/dashboard-banner", + headers=headers, + json=banner_update(translations=legacy_translations), + ) + assert missing_sv.status_code == 422 + + +@pytest.mark.asyncio +async def test_existing_twelve_locale_banner_remains_readable_until_danish_is_added( + client, admin_user, db_session +): + _, headers = admin_user + translations = banner_translations() + translations.pop("da") + translations.pop("fi") + translations.pop("hr") + db_session.add( + DashboardBanner( + id=1, + source_locale="en", + is_active=True, + translations=translations, + revision=6, + ) + ) + await db_session.commit() + + response = await client.get("/api/admin/dashboard-banner", headers=headers) + assert response.status_code == 200 + assert response.json()["translations"] == translations + assert response.json()["revision"] == 6 + + missing_da = await client.put( + "/api/admin/dashboard-banner", + headers=headers, + json=banner_update(translations=translations), + ) + assert missing_da.status_code == 422 + + +@pytest.mark.asyncio +async def test_existing_thirteen_locale_banner_remains_readable_until_finnish_is_added( + client, admin_user, db_session +): + _, headers = admin_user + translations = banner_translations() + translations.pop("fi") + translations.pop("hr") + db_session.add( + DashboardBanner( + id=1, + source_locale="en", + is_active=True, + translations=translations, + revision=7, + ) + ) + await db_session.commit() + + response = await client.get("/api/admin/dashboard-banner", headers=headers) + assert response.status_code == 200 + assert response.json()["translations"] == translations + assert response.json()["revision"] == 7 + + missing_fi = await client.put( + "/api/admin/dashboard-banner", + headers=headers, + json=banner_update(translations=translations), + ) + assert missing_fi.status_code == 422 + + +@pytest.mark.asyncio +async def test_existing_fourteen_locale_banner_remains_readable_until_croatian_is_added( + client, admin_user, db_session +): + _, headers = admin_user + translations = banner_translations() + translations.pop("hr") + db_session.add( + DashboardBanner( + id=1, + source_locale="en", + is_active=True, + translations=translations, + revision=8, + ) + ) + await db_session.commit() + + response = await client.get("/api/admin/dashboard-banner", headers=headers) + assert response.status_code == 200 + assert response.json()["translations"] == translations + assert response.json()["revision"] == 8 + + missing_hr = await client.put( + "/api/admin/dashboard-banner", + headers=headers, + json=banner_update(translations=translations), + ) + assert missing_hr.status_code == 422 + + @pytest.mark.asyncio async def test_dashboard_banner_admin_endpoints_require_admin(client, test_user): _, user_headers = test_user @@ -108,12 +280,50 @@ async def fake_structured_output(messages, schema): "subtitle": "Brief interruption", "description": "Service returns shortly.", } + assert "tr" in translations + assert "sv" in translations + assert "da" in translations + assert "fi" in translations + assert "hr" in translations # Translation is a preview and must not create the singleton. banner_response = await client.get("/api/admin/dashboard-banner", headers=headers) assert banner_response.json() is None +@pytest.mark.asyncio +async def test_translate_banner_accepts_turkish_source(client, admin_user, monkeypatch): + _, headers = admin_user + generated = banner_translations("Generated") + + async def fake_structured_output(messages, schema): + assert "all fifteen requested locales" in messages[0]["content"] + assert "Source locale: tr" in messages[0]["content"] + return schema(translations=generated) + + monkeypatch.setattr( + "app.routers.admin_dashboard_banner.llm_adapter.structured_output", + fake_structured_output, + ) + response = await client.post( + "/api/admin/dashboard-banner/translate", + headers=headers, + json={ + "source_locale": "tr", + "title": " Duyuru ", + "subtitle": " Önemli ", + "description": " Ayrıntılar ", + }, + ) + + assert response.status_code == 200 + assert response.json()["translations"]["tr"] == { + "title": "Duyuru", + "subtitle": "Önemli", + "description": "Ayrıntılar", + } + + @pytest.mark.asyncio async def test_translate_banner_returns_safe_502_for_llm_failure(client, admin_user, monkeypatch): _, headers = admin_user diff --git a/backend/tests/test_email_service.py b/backend/tests/test_email_service.py index c2a31aefb..49ac712ec 100644 --- a/backend/tests/test_email_service.py +++ b/backend/tests/test_email_service.py @@ -1,3 +1,11 @@ +from html import escape +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.schemas.auth import SUPPORTED_UI_LOCALES +from app.services import email_service from app.services.email_service import _render_template, _safe_html @@ -21,11 +29,75 @@ def test_render_template_escapes_user_controlled_values(): assert "" not in html assert 'Click' not in html assert "urgent" not in html + assert '' in html assert "<img src=x onerror=alert(1)>" in html assert "<a href="https://phishing.example">Click</a>" in html assert "<strong>urgent</strong> & dangerous" in html +@pytest.mark.parametrize("locale", [*sorted(SUPPORTED_UI_LOCALES), "unsupported"]) +@pytest.mark.parametrize( + ("send", "args", "catalog"), + [ + ( + email_service.send_verification_email, + ("student@example.com", "Student", "test-token"), + email_service._VERIFY_I18N, + ), + ( + email_service.send_reset_password_email, + ("student@example.com", "Student", "test-token"), + email_service._RESET_I18N, + ), + ( + email_service.send_welcome_email, + ("student@example.com", "Student"), + email_service._WELCOME_I18N, + ), + ( + email_service.send_account_deleted_email, + ("student@example.com", "Student"), + email_service._DELETION_I18N, + ), + ( + email_service.send_contact_email, + ("student@example.com", "Subject", "Description"), + email_service._CONTACT_I18N, + ), + ( + email_service.send_feedback_notification, + ("feature", "Title", "Description", "student", 1), + email_service._FEEDBACK_I18N, + ), + ( + email_service.send_review_notification, + ("Student", 5, None, "en-GB", 1), + email_service._REVIEW_I18N, + ), + ], + ids=["verification", "reset", "welcome", "deletion", "contact", "feedback", "review"], +) +async def test_email_declares_the_language_of_its_rendered_content( + monkeypatch, locale, send, args, catalog +): + send_message = AsyncMock() + monkeypatch.setattr(email_service.settings, "EMAIL_ENABLED", True) + monkeypatch.setattr(email_service.settings, "CONTACT_EMAIL", "admin@example.com") + monkeypatch.setattr(email_service, "_get_mail_config", lambda: None) + monkeypatch.setattr( + email_service, "FastMail", lambda _: SimpleNamespace(send_message=send_message) + ) + + await send(*args, locale=locale) + + send_message.assert_awaited_once() + message = send_message.await_args.args[0] + expected_locale = locale if locale in SUPPORTED_UI_LOCALES else "en" + assert f'' in message.body + assert escape(catalog[expected_locale]["footer"], quote=True) in message.body + assert "{{" not in message.body + + def test_render_template_preserves_explicitly_trusted_html(): html = _render_template( "verify_email.html", diff --git a/frontend/src/app/(app)/admin/feedback/page.tsx b/frontend/src/app/(app)/admin/feedback/page.tsx index b8df9a112..d3a17f1a5 100644 --- a/frontend/src/app/(app)/admin/feedback/page.tsx +++ b/frontend/src/app/(app)/admin/feedback/page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'next/navigation' -import { useTranslations } from 'next-intl' +import { useLocale, useTranslations } from 'next-intl' import { Bug, FilterX, @@ -68,8 +68,8 @@ const STATUS_TONES: Record< declined: 'danger', } -function formatDate(iso: string): string { - return new Date(iso).toLocaleDateString(undefined, { +function formatDate(iso: string, locale: string): string { + return new Date(iso).toLocaleDateString(locale, { year: 'numeric', month: 'short', day: 'numeric', @@ -79,6 +79,7 @@ function formatDate(iso: string): string { export default function AdminFeedbackPage() { const t = useTranslations('feedback') const tAdmin = useTranslations('admin') + const locale = useLocale() const searchParams = useSearchParams() const [entries, setEntries] = useState([]) @@ -494,7 +495,7 @@ export default function AdminFeedbackPage() { {t('by')} {entry.author.display_name} - · {formatDate(entry.created_at)} + · {formatDate(entry.created_at, locale)}

@@ -595,7 +596,7 @@ export default function AdminFeedbackPage() { {t('by')} {entry.author.display_name} - · {formatDate(entry.created_at)} + · {formatDate(entry.created_at, locale)}

diff --git a/frontend/src/app/(app)/admin/reviews/page.tsx b/frontend/src/app/(app)/admin/reviews/page.tsx index ac939c972..8b665c6cb 100644 --- a/frontend/src/app/(app)/admin/reviews/page.tsx +++ b/frontend/src/app/(app)/admin/reviews/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' -import { useTranslations } from 'next-intl' +import { useLocale, useTranslations } from 'next-intl' import { CheckCircle2, FilterX, @@ -32,18 +32,14 @@ const PAGE_SIZE = 10 type ApprovalFilter = 'all' | 'pending' | 'approved' -function formatDate(iso: string): string { - return new Date(iso).toLocaleDateString(undefined, { +function formatDate(iso: string, locale: string): string { + return new Date(iso).toLocaleDateString(locale, { year: 'numeric', month: 'short', day: 'numeric', }) } -function languageLabel(code: string): string { - return getLanguageByCode(code)?.nameEn ?? code -} - function stars(rating: number): string { return '★'.repeat(rating) + '☆'.repeat(Math.max(0, 5 - rating)) } @@ -51,6 +47,14 @@ function stars(rating: number): string { export default function AdminReviewsPage() { const t = useTranslations('adminReviews') const tAdmin = useTranslations('admin') + const tTarget = useTranslations('targetLanguages') + const tStars = useTranslations('landingReviews') + const locale = useLocale() + + function languageLabel(code: string): string { + const language = getLanguageByCode(code) + return language ? tTarget(language.code) : code + } const [reviews, setReviews] = useState([]) const [total, setTotal] = useState(0) const [page, setPage] = useState(0) @@ -255,7 +259,9 @@ export default function AdminReviewsPage() { {stars(review.rating)} @@ -264,7 +270,7 @@ export default function AdminReviewsPage() { {t('learningLanguage', { language: languageLabel(review.target_language), })}{' '} - · {formatDate(review.created_at)} + · {formatDate(review.created_at, locale)}

{review.comment || t('ratingOnly')} diff --git a/frontend/src/app/(app)/admin/system/page.tsx b/frontend/src/app/(app)/admin/system/page.tsx index 1ce66da38..2f4cd3a4b 100644 --- a/frontend/src/app/(app)/admin/system/page.tsx +++ b/frontend/src/app/(app)/admin/system/page.tsx @@ -21,6 +21,11 @@ const BANNER_LOCALES = [ 'nl', 'pl', 'ro', + 'tr', + 'sv', + 'da', + 'fi', + 'hr', ] as const type BannerLocale = (typeof BANNER_LOCALES)[number] @@ -30,7 +35,7 @@ interface AdminDashboardBanner { source_locale: BannerLocale is_active: boolean revision: number - translations: BannerTranslations + translations: Partial created_at: string updated_at: string } @@ -50,6 +55,12 @@ function emptyTranslations(): BannerTranslations { export default function AdminSystemPage() { const t = useTranslations('admin') const currentLocale = useLocale() + const sortedBannerLocales = [...BANNER_LOCALES].sort((a, b) => + t(`dashboardBanner.locales.${a}`).localeCompare( + t(`dashboardBanner.locales.${b}`), + currentLocale + ) + ) const defaultLocale = BANNER_LOCALES.includes(currentLocale as BannerLocale) ? (currentLocale as BannerLocale) : 'en' @@ -80,8 +91,10 @@ export default function AdminSystemPage() { if (banner) { setSourceLocale(banner.source_locale) setEditorLocale(banner.source_locale) - setSource({ ...banner.translations[banner.source_locale] }) - setTranslations(banner.translations) + setSource({ + ...(banner.translations[banner.source_locale] ?? EMPTY_TRANSLATION), + }) + setTranslations({ ...emptyTranslations(), ...banner.translations }) setHasTranslations(true) setIsActive(banner.is_active) setRevision(banner.revision) @@ -163,11 +176,15 @@ export default function AdminSystemPage() { const saved: AdminDashboardBanner = await response.json() setRevision(saved.revision) setUpdatedAt(saved.updated_at) - setTranslations(saved.translations) + const savedTranslations = { + ...emptyTranslations(), + ...saved.translations, + } + setTranslations(savedTranslations) setIsActive(saved.is_active) useConfigStore.setState({ dashboardBanner: saved.is_active - ? { revision: saved.revision, translations: saved.translations } + ? { revision: saved.revision, translations: savedTranslations } : null, }) setBannerSuccess(t('dashboardBanner.saveSuccess')) @@ -299,12 +316,14 @@ export default function AdminSystemPage() {

- {levelTopics.length} topic{levelTopics.length !== 1 ? 's' : ''} + {t('topicsFound', { count: levelTopics.length })}
diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index 7dc83d92e..aa0d6a670 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.20 + v1.9.25

diff --git a/frontend/src/app/(app)/progress/page.tsx b/frontend/src/app/(app)/progress/page.tsx index ab532057f..6fbcb34e4 100644 --- a/frontend/src/app/(app)/progress/page.tsx +++ b/frontend/src/app/(app)/progress/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react' import Link from 'next/link' -import { useTranslations } from 'next-intl' +import { useLocale, useTranslations } from 'next-intl' import { PageLoading } from '@/components/ui/page-loading' import { apiFetch } from '@/lib/api' import { useLanguageStore } from '@/store/language' @@ -156,6 +156,7 @@ function UnitCompetencyBlock({ // ── Page ────────────────────────────────────────────────────────────────────── export default function ProgressPage() { + const locale = useLocale() const t = useTranslations('progress') const tVocab = useTranslations('vocabulary') const activeLanguage = useLanguageStore((s) => s.activeLanguage) @@ -259,8 +260,11 @@ export default function ProgressPage() { {summary && (
{[ - { label: t('xp'), value: summary.total_xp.toLocaleString() }, - { label: t('streak'), value: `${summary.current_streak}d 🔥` }, + { + label: t('xp'), + value: summary.total_xp.toLocaleString(locale), + }, + { label: t('streak'), value: `${summary.current_streak} 🔥` }, { label: t('lessons'), value: summary.total_lessons }, { label: t('accuracy'), diff --git a/frontend/src/app/(app)/settings/languages/page.tsx b/frontend/src/app/(app)/settings/languages/page.tsx index 797410cba..6c7b27666 100644 --- a/frontend/src/app/(app)/settings/languages/page.tsx +++ b/frontend/src/app/(app)/settings/languages/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useState, useCallback } from 'react' import Link from 'next/link' import Image from 'next/image' import { useRouter } from 'next/navigation' -import { useTranslations } from 'next-intl' +import { useLocale, useTranslations } from 'next-intl' import { useLanguageStore } from '@/store/language' import { getLanguageByCode, @@ -16,6 +16,7 @@ import { PageLoading } from '@/components/ui/page-loading' import type { UserLanguageInfo } from '@/store/language' export default function MyLanguagesPage() { + const locale = useLocale() const t = useTranslations('languages') const tTarget = useTranslations('targetLanguages') const tSettings = useTranslations('settings') @@ -207,7 +208,8 @@ export default function MyLanguagesPage() { {progress && ( <> - {t('xpLabel')}: {progress.total_xp.toLocaleString()} + {t('xpLabel')}:{' '} + {progress.total_xp.toLocaleString(locale)} {t('streakLabel')}: {progress.current_streak}d diff --git a/frontend/src/app/(app)/vocabulary/[setId]/page.tsx b/frontend/src/app/(app)/vocabulary/[setId]/page.tsx index e13d141ca..6a90fd8ed 100644 --- a/frontend/src/app/(app)/vocabulary/[setId]/page.tsx +++ b/frontend/src/app/(app)/vocabulary/[setId]/page.tsx @@ -32,7 +32,7 @@ export default function VocabularySetPage({ const router = useRouter() const t = useTranslations('vocabulary') const tCommon = useTranslations('common') - const tTargetLang = useTranslations('targetLanguages') + const tLang = useTranslations('languages') const activeLanguage = useLanguageStore((s) => s.activeLanguage) const user = useAuthStore((s) => s.user) const [vocabSet, setVocabSet] = useState(null) @@ -47,7 +47,7 @@ export default function VocabularySetPage({ const [loadingNativeHelp, setLoadingNativeHelp] = useState(false) const [nativeHelpError, setNativeHelpError] = useState(false) const nativeLanguageName = user?.native_language - ? tTargetLang(user.native_language) + ? tLang(user.native_language) : '' useEffect(() => { @@ -115,7 +115,9 @@ export default function VocabularySetPage({ if (!res.ok) { const d = await res.json().catch(() => ({})) throw new Error( - (d as { detail?: string }).detail ?? `Error ${res.status}` + (d as { detail?: string }).detail === 'No active study plan found' + ? 'No active study plan found' + : t('addFailed') ) } const data = (await res.json()) as { created: number } @@ -125,7 +127,7 @@ export default function VocabularySetPage({ setError( msg === 'No active study plan found' ? tCommon('noActivePlan') - : msg || 'Failed to add flashcards.' + : t('addFailed') ) } finally { setAdding(false) diff --git a/frontend/src/app/(app)/vocabulary/page.tsx b/frontend/src/app/(app)/vocabulary/page.tsx index 8da9aa33c..ef98ebe9e 100644 --- a/frontend/src/app/(app)/vocabulary/page.tsx +++ b/frontend/src/app/(app)/vocabulary/page.tsx @@ -83,7 +83,7 @@ export default function VocabularyIndexPage() {

- {vocabSets.length} {t('sets')} · {totalWords} {t('words')} + {t('sets', { count: vocabSets.length })} · {totalWords} {t('words')} {usedLevels.length > 0 && ( <> {' · '} @@ -142,7 +142,7 @@ export default function VocabularyIndexPage() {

- {sets.length} set{sets.length !== 1 ? 's' : ''} ·{' '} + {t('sets', { count: sets.length })} ·{' '} {sets.reduce((a, s) => a + s.words.length, 0)} {t('words')}
diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx index ebe3b51ee..7f4594949 100644 --- a/frontend/src/app/(auth)/forgot-password/page.tsx +++ b/frontend/src/app/(auth)/forgot-password/page.tsx @@ -26,8 +26,8 @@ export default function ForgotPasswordPage() { }) if (!res.ok) throw new Error(t('error')) setSent(true) - } catch (err: unknown) { - setError(err instanceof Error ? err.message : t('error')) + } catch { + setError(t('error')) } finally { setLoading(false) } diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index b092a14c2..5b8336632 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -71,7 +71,12 @@ function LoginForm() { if (meRes.ok) setUser(mapUser(await meRes.json())) router.push('/dashboard') } catch (err: unknown) { - setError(err instanceof Error ? err.message : t('loginFailed')) + setError( + err instanceof Error && + (err.message === t('error') || err.message === t('invalidEmail')) + ? err.message + : t('loginFailed') + ) } finally { setLoading(false) } diff --git a/frontend/src/app/(auth)/onboarding/page.tsx b/frontend/src/app/(auth)/onboarding/page.tsx index ba841744a..aa85a0ac2 100644 --- a/frontend/src/app/(auth)/onboarding/page.tsx +++ b/frontend/src/app/(auth)/onboarding/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import Image from 'next/image' -import { useTranslations } from 'next-intl' +import { useLocale, useTranslations } from 'next-intl' import { Loader2 } from 'lucide-react' import { apiFetch } from '@/lib/api' import { splitYearlyCta, type BillingInterval } from '@/lib/billing-copy' @@ -38,6 +38,7 @@ export default function OnboardingPage() { const t = useTranslations('onboarding') const tCommon = useTranslations('common') const tLang = useTranslations('targetLanguages') + const locale = useLocale() const router = useRouter() const searchParams = useSearchParams() const setUser = useAuthStore((s) => s.setUser) @@ -125,8 +126,8 @@ export default function OnboardingPage() { } else { router.push('/dashboard') } - } catch (err: unknown) { - setError(err instanceof Error ? err.message : t('saveFailed')) + } catch { + setError(t('saveFailed')) } finally { setLoading(false) } @@ -155,13 +156,12 @@ export default function OnboardingPage() { body: JSON.stringify({ plan: interval }), }) if (!res.ok) { - const data = await res.json().catch(() => ({})) - throw new Error(data.detail ?? t('trialError')) + throw new Error(t('trialError')) } const { url } = await res.json() window.location.assign(url) - } catch (err: unknown) { - setCheckoutError(err instanceof Error ? err.message : t('trialError')) + } catch { + setCheckoutError(t('trialError')) setCheckoutLoading(null) } } @@ -171,7 +171,7 @@ export default function OnboardingPage() { const trialEligible = !user?.trial_used const yearlyCta = splitYearlyCta( t(trialEligible ? 'trialCtaYearly' : 'trialCtaYearlyTrialUsed', { - price: String(priceYearly), + price: new Intl.NumberFormat(locale).format(priceYearly), }) ) @@ -395,7 +395,9 @@ export default function OnboardingPage() { ? 'trialCtaMonthly' : 'trialCtaMonthlyTrialUsed', { - price: String(priceMonthly), + price: new Intl.NumberFormat(locale).format( + priceMonthly + ), } ) ) : ( diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx index 82c9a178f..23991ef39 100644 --- a/frontend/src/app/(auth)/register/page.tsx +++ b/frontend/src/app/(auth)/register/page.tsx @@ -22,6 +22,11 @@ const LANGUAGES = [ 'nl', 'ro', 'ru', + 'tr', + 'sv', + 'da', + 'fi', + 'hr', ] as const type SelectedPlan = 'monthly' | 'yearly' @@ -123,7 +128,20 @@ function RegisterForm() { selectedPlan ? `/onboarding?plan=${selectedPlan}` : '/onboarding' ) } catch (err: unknown) { - setError(err instanceof Error ? err.message : t('error')) + const knownErrors = [ + t('error'), + t('usernameTaken'), + t('emailTaken'), + t('registrationClosed'), + t('invalidInvite'), + t('invalidEmail'), + t('invalidPassword'), + ] + setError( + err instanceof Error && knownErrors.includes(err.message) + ? err.message + : t('error') + ) } finally { setLoading(false) } diff --git a/frontend/src/app/(auth)/reset-password/page.tsx b/frontend/src/app/(auth)/reset-password/page.tsx index c53306f60..22d46023c 100644 --- a/frontend/src/app/(auth)/reset-password/page.tsx +++ b/frontend/src/app/(auth)/reset-password/page.tsx @@ -9,6 +9,8 @@ import { apiFetch } from '@/lib/api' function ResetPasswordContent() { const t = useTranslations('auth.resetPassword') + const tCommon = useTranslations('common') + const tRegister = useTranslations('auth.register') const router = useRouter() const searchParams = useSearchParams() const token = searchParams.get('token') ?? '' @@ -25,10 +27,15 @@ function ResetPasswordContent() { setError(t('mismatch')) return } - if (password.length < 8) { + const passwordLength = [...password].length + if (passwordLength < 10) { setError(t('tooShort')) return } + if (passwordLength > 25) { + setError(tRegister('invalidPassword')) + return + } setLoading(true) try { const res = await apiFetch('/api/auth/reset-password', { @@ -37,13 +44,24 @@ function ResetPasswordContent() { body: JSON.stringify({ token, new_password: password }), }) if (!res.ok) { - const data = await res.json().catch(() => ({})) - throw new Error(data.detail || t('error')) + throw new Error( + res.status === 400 || res.status === 404 + ? t('error') + : res.status === 422 + ? tRegister('invalidPassword') + : tCommon('errorMessage') + ) } setDone(true) setTimeout(() => router.push('/login'), 2000) } catch (err: unknown) { - setError(err instanceof Error ? err.message : t('error')) + setError( + err instanceof Error && + (err.message === t('error') || + err.message === tRegister('invalidPassword')) + ? err.message + : tCommon('errorMessage') + ) } finally { setLoading(false) } @@ -78,7 +96,10 @@ function ResetPasswordContent() {
)} {error && ( -
+
✕ {error}
)} @@ -89,8 +110,15 @@ function ResetPasswordContent() { value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" + aria-describedby="reset-password-requirements" className="bg-fl-bg border-fl-border text-fl-fg placeholder:text-fl-muted-4 focus:border-fl-border-2 w-full border px-4 py-3 font-mono text-xs transition-colors focus:outline-none" /> +

+ {tRegister('invalidPassword')} +