~jan0sch/smederee

Showing details for patch cfb11b928cf993c0f2d26a65a8686e9f09240dec.
2023-03-26 (Sun), 8:01 AM - Jens Grassel - cfb11b928cf993c0f2d26a65a8686e9f09240dec

Add localisation support (for signed in users).

- add field for language code to account
- add configuration option for language to account settings
- fix issue with default properties (messages_en -> messages)
- load language (or default) in views
- add some german translations
Summary of changes
5 files added
  • modules/hub/src/main/resources/db/migration/hub/V4__add_language.sql
  • modules/hub/src/main/resources/messages.properties
  • modules/hub/src/main/resources/messages_de.properties
  • modules/i18n/src/test/resources/messages.properties
  • modules/i18n/src/test/resources/messages_de.properties
21 files modified with 316 lines added and 122 lines removed
  • build.sbt with 1 added and 0 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala with 9 added and 2 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala with 20 added and 0 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala with 6 added and 3 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/Generators.scala with 8 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/Account.scala with 10 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala with 12 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala with 90 added and 49 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala with 6 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala with 4 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 4 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/LandingPageRoutes.scala with 26 added and 12 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala with 2 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 34 added and 17 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala with 9 added and 5 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala with 11 added and 6 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html with 38 added and 14 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/Generators.scala with 8 added and 1 removed lines
  • modules/i18n/src/main/scala/de/smederee/i18n/LanguageCode.scala with 4 added and 0 removed lines
  • modules/i18n/src/main/scala/de/smederee/i18n/Messages.scala with 4 added and 3 removed lines
  • modules/i18n/src/test/scala/de/smederee/i18n/MessagesTest.scala with 10 added and 2 removed lines
2 files removed
  • modules/hub/src/main/resources/messages_en.properties
  • modules/i18n/src/test/resources/messages_en.properties
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt
--- old-smederee/build.sbt	2025-01-31 07:57:26.134415123 +0000
+++ new-smederee/build.sbt	2025-01-31 07:57:26.142415137 +0000
@@ -285,6 +285,7 @@
       name := "i18n",
       version := "0.6.0-SNAPSHOT",
       libraryDependencies ++= Seq(
+        library.catsCore,
         library.logback,
         library.munit           % Test,
         library.munitDiscipline % Test,
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala	2025-01-31 07:57:26.142415137 +0000
@@ -28,6 +28,7 @@
 import com.typesafe.config.ConfigFactory
 import de.smederee.email.EmailAddress
 import de.smederee.hub.config._
+import de.smederee.i18n.LanguageCode
 import de.smederee.security._
 import org.flywaydb.core.Flyway
 import pureconfig._
@@ -244,19 +245,25 @@
       for {
         statement <- IO.delay(
           con.prepareStatement(
-            """SELECT uid, name, email, password, created_at, updated_at, validated_email FROM "hub"."accounts" WHERE uid = ? LIMIT 1"""
+            """SELECT uid, name, email, password, created_at, updated_at, validated_email, language FROM "hub"."accounts" WHERE uid = ? LIMIT 1"""
           )
         )
         _      <- IO.delay(statement.setObject(1, uid))
         result <- IO.delay(statement.executeQuery)
         account <- IO.delay {
           if (result.next()) {
+            val language =
+              if (result.getString("language") =!= null)
+                LanguageCode.from(result.getString("language"))
+              else
+                None
             Option(
               Account(
                 uid = uid,
                 name = Username(result.getString("name")),
                 email = EmailAddress(result.getString("email")),
-                validatedEmail = result.getBoolean("validated_email")
+                validatedEmail = result.getBoolean("validated_email"),
+                language = language
               )
             )
           } else {
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala	2025-01-31 07:57:26.142415137 +0000
@@ -199,6 +199,26 @@
     }
   }
 
+  test("setLanguage must set the language") {
+    genValidAccount.sample match {
+      case None => fail("Could not generate data samples!")
+      case Some(account) =>
+        val language = genLanguageCode.sample
+        val dbConfig = configuration.database
+        val tx       = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo     = new DoobieAccountManagementRepository[IO](tx)
+        val hash     = PasswordHash("Yet another weak password!")
+        val test = for {
+          _               <- createAccount(account, hash)
+          _               <- repo.setLanguage(account.uid, language)
+          modifiedAccount <- loadAccount(account.uid)
+        } yield modifiedAccount
+        test.map { modifiedAccount =>
+          assert(modifiedAccount.exists(_.language === language), "Written language field does not match!")
+        }
+    }
+  }
+
   test("setValidationToken must set the validation token") {
     genValidAccount.sample match {
       case None => fail("Could not generate data samples!")
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-01-31 07:57:26.142415137 +0000
@@ -269,7 +269,8 @@
             repo.owner.uid,
             repo.owner.name,
             EmailAddress(s"${repo.owner.name}@example.com"),
-            validatedEmail = true
+            validatedEmail = true,
+            None
           )
         )
         val expectedRepoList = vcsRepositories.filter(_.isPrivate === false)
@@ -306,7 +307,8 @@
             repo.owner.uid,
             repo.owner.name,
             EmailAddress(s"${repo.owner.name}@example.com"),
-            validatedEmail = true
+            validatedEmail = true,
+            None
           )
         )
         val expectedRepoList = vcsRepositories.filter(_.isPrivate === false)
@@ -346,7 +348,8 @@
             repo.owner.uid,
             repo.owner.name,
             EmailAddress(s"${repo.owner.name}@example.com"),
-            validatedEmail = true
+            validatedEmail = true,
+            None
           )
         )
         val expectedRepoList = vcsRepositories
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala	2025-01-31 07:57:26.142415137 +0000
@@ -23,6 +23,7 @@
 
 import cats.syntax.all._
 import de.smederee.email.EmailAddress
+import de.smederee.i18n.LanguageCode
 import de.smederee.security._
 
 import org.scalacheck._
@@ -34,6 +35,11 @@
   val MinimumYear: Int = -4713  // Lowest year supported by PostgreSQL
   val MaximumYear: Int = 294276 // Highest year supported by PostgreSQL
 
+  val genLocale: Gen[Locale] = Gen.oneOf(Locale.getAvailableLocales.toList)
+  given Arbitrary[Locale]    = Arbitrary(genLocale)
+
+  val genLanguageCode: Gen[LanguageCode] = genLocale.map(_.getISO3Language).filter(_.nonEmpty).map(LanguageCode.apply)
+
   val genFiniteDuration: Gen[FiniteDuration] =
     Gen.choose(0, Int.MaxValue).map(seconds => FiniteDuration(seconds, SECONDS))
 
@@ -108,7 +114,8 @@
     email          <- genValidEmail
     name           <- genValidUsername
     validatedEmail <- Gen.oneOf(List(false, true))
-  } yield Account(uid = id, name = name, email = email, validatedEmail = validatedEmail)
+    language       <- Gen.option(genLanguageCode)
+  } yield Account(uid = id, name = name, email = email, validatedEmail = validatedEmail, language = language)
 
   given Arbitrary[Account] = Arbitrary(genValidAccount)
 
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/hub/V4__add_language.sql new-smederee/modules/hub/src/main/resources/db/migration/hub/V4__add_language.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/hub/V4__add_language.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/hub/V4__add_language.sql	2025-01-31 07:57:26.142415137 +0000
@@ -0,0 +1,4 @@
+ALTER TABLE "hub"."accounts"
+  ADD COLUMN "language" CHARACTER VARYING(3) DEFAULT NULL;
+
+COMMENT ON COLUMN "hub"."accounts"."language" IS 'The ISO-639 language code of the preferred language of the user.';
diff -rN -u old-smederee/modules/hub/src/main/resources/messages_de.properties new-smederee/modules/hub/src/main/resources/messages_de.properties
--- old-smederee/modules/hub/src/main/resources/messages_de.properties	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/messages_de.properties	2025-01-31 07:57:26.142415137 +0000
@@ -0,0 +1,20 @@
+# Smederee - Hub
+# Resource bundle for translations.
+#
+# *** FILE ENCODING MUST BE UTF-8! ***
+#
+# File structure
+# ==============
+# 1. Translation keys are supposed to be grouped by semantic topic (e.g. 
+#    error.foo.bar or ui.button.logout).
+# 2. Grouping continues downward if it makes sense, e.g.
+#    error.forbidden.title, error.forbidden.message.
+#
+errors.account.not-validated=Verzeihung, aber Dein Konto wurde noch nicht bestätigt und Du darfst deshalb die gewünschte Aktion nicht durchführen. Bitte bestätige Dein Konto über die Einstellungen.
+errors.forbidden.title=403 - Verboten
+
+# Forms
+form.account.delete.button.submit=Lösche mein Konto!
+form.account.delete.i-am-sure=Ja, ich bin sicher, daß ich mein Konto und alle zugehörigen Daten löschen möchte!
+form.account.delete.notice=Wenn Du Dein Konto löschst, werden alle damit verbundenen Daten entfernt. Diese Aktion KANN NICHT rückgängig gemacht werden!
+form.account.delete.password=Passwort
diff -rN -u old-smederee/modules/hub/src/main/resources/messages_en.properties new-smederee/modules/hub/src/main/resources/messages_en.properties
--- old-smederee/modules/hub/src/main/resources/messages_en.properties	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/main/resources/messages_en.properties	1970-01-01 00:00:00.000000000 +0000
@@ -1,262 +0,0 @@
-# Smederee - Hub
-# Resource bundle for translations.
-#
-# *** FILE ENCODING MUST BE UTF-8! ***
-#
-# File structure
-# ==============
-# 1. Translation keys are supposed to be grouped by semantic topic (e.g. 
-#    error.foo.bar or ui.button.logout).
-# 2. Grouping continues downward if it makes sense, e.g.
-#    error.forbidden.title, error.forbidden.message.
-#
-errors.account.not-validated=Sorry, but your account has not been validated and is therefore not allowed to perform the desired action. Please validate your account. You can do so on the settings page.
-errors.forbidden.title=403 - Forbidden
-
-# Forms
-form.account.delete.button.submit=Delete my account!
-form.account.delete.i-am-sure=Yes, I am sure that I want to delete my account and all related data!
-form.account.delete.notice=If you delete your account then all related data will be permanently removed. This action CANNOT be undone!
-form.account.delete.password=Password
-form.account.validate-email.notice=You have not yet validated your email address, therefore some operations are not yet allowed. If you have not received a validation email from us, please use the button below to send the email.
-form.account.validate-email.button.submit=Send validation email
-form.create-repo.button.submit=Create repository
-form.create-repo.name=Name
-form.create-repo.name.placeholder=Please enter a repository name.
-form.create-repo.name.help=A repository name must start with a letter or number and must contain only alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long.
-form.create-repo.is-private=Private Repository
-form.create-repo.is-private.help=A private repository can only be accessed by the owner and accounts which have been given permissions to do so.
-form.create-repo.description=Description
-form.create-repo.description.placeholder=
-form.create-repo.description.help=An optional short description of you repo / project.
-form.create-repo.website=Website
-form.create-repo.website.placeholder=https://example.com
-form.create-repo.website.help=An optional URI pointing to the website of your project.
-form.repository.delete.button.submit=Delete repository
-form.repository.delete.i-am-sure=Yes, I am sure that I want to delete the repository {0}!
-form.repository.delete.notice=This action CANNOT be undone! Please be careful.
-form.repository.delete.title=Delete repository {0}
-form.edit-repo.button.submit=Edit repository
-form.edit-repo.name=Name
-form.edit-repo.name.help=A repository name must start with a letter or number and must contain only alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long.
-form.edit-repo.is-private=Private Repository
-form.edit-repo.is-private.help=A private repository can only be accessed by the owner and accounts which have been given permissions to do so.
-form.edit-repo.description=Description
-form.edit-repo.description.help=An optional short description of you repo / project.
-form.edit-repo.website=Website
-form.edit-repo.website.placeholder=https://example.com
-form.edit-repo.website.help=An optional URI pointing to the website of your project.
-form.fork.button.submit=Clone to your account.
-form.label.create.button.submit=Create label
-form.label.colour=Colour
-form.label.colour.help=Pick a colour which will be used as background colour for the label.
-form.label.description=Description
-form.label.description.help=The description is optional and may contain up to 254 characters.
-form.label.description.placeholder=description
-form.label.name=Name
-form.label.name.help=A label name can be up to 40 characters long, must not be empty and must be unique in the scope of a project.
-form.label.name.placeholder=label name
-form.label.edit.button.submit=Save label
-form.label.delete.button.submit=Delete
-form.label.delete.i-am-sure=Yes, I'm sure!
-form.login.button.submit=Login
-form.login.password=Password
-form.login.password.placeholder=Please enter your password here.
-form.login.username=Username
-form.login.username.placeholder=Please enter your username.
-form.milestone.create.button.submit=Create milestone
-form.milestone.due-date=Due date
-form.milestone.due-date.help=You may pick an optional date to indicate until when the milestone is planned to be reached.
-form.milestone.description=Description
-form.milestone.description.help=An optional description of the milestone.
-form.milestone.description.placeholder=description
-form.milestone.title=Title
-form.milestone.title.help=A milestone title can be up to 64 characters long, must not be empty and must be unique in the scope of a project.
-form.milestone.title.placeholder=milestone title
-form.milestone.edit.button.submit=Save milestone
-form.milestone.delete.button.submit=Delete
-form.milestone.delete.i-am-sure=Yes, I'm sure!
-form.signup.button.submit=Sign up for an account
-form.signup.email=Email address
-form.signup.email.help=Please enter your email address.
-form.signup.email.placeholder=someone@somewhere.org
-form.signup.password=Password
-form.signup.password.help=Your password must be at least 12 characters long.
-form.signup.password.placeholder=Please choose a secure password!
-form.signup.username=Username
-form.signup.username.help=A username is required because it will be used to group your projects and other stuff. It must be between 2 and 31 characters long and contain only lowercase alphanumeric characters and start with a character (letter).
-form.signup.username.placeholder=Please choose your username.
-form.ssh.add.button.reset=Cancel
-form.ssh.add.button.submit=Add SSH key
-form.ssh.add.key=Key content
-form.ssh.add.key.help=Please note that we currently only support RSA keys!
-form.ssh.add.key.placeholder=The content of your key file (usually located in ~/.ssh/id_rsa.pub) starting with ssh-rsa or ssh-ed25519.
-form.ssh.add.name=Name
-form.ssh.add.name.help=If left empty it will be taken from the ssh key comment (if that exists).
-form.ssh.delete.button.submit=Delete key
-form.ssh.delete.i-am-sure=Yes, I'm sure!
-
-# Global / generic translations
-global.alpha=Alpha Notice
-global.contact=Contact
-global.copyright=© 2022 Wegtam GmbH
-global.error=Error
-global.imprint=Imprint/Impressum
-global.login=Login
-global.logout=Logout
-global.navbar.top.logo=Smederee
-global.navbar.top.repositories.all=All repositories
-global.navbar.top.repositories.yours=Your repositories
-global.navbar.top.repository.new=New repository
-global.navbar.top.settings=Settings
-global.privacy=Privacy Policy
-global.signup=Sign Up
-global.terms.of.use=Terms of Use
-
-# Landing Pages
-landingpage.alpha.future-goals.intro=We intend to develop the Smederee into a production ready project during the alpha phase. Afterwards we''ll phase over into a beta phase during which remaining bugs are squashed and the payment system will be introduced.
-landingpage.alpha.future-goals.title=Goals for the alpha phase.
-landingpage.alpha.guarantees.data-safety.text=Frequent backups are performed and everything is stored on servers inside the EU which implies that it is protected by the EU GDPR regulation, so what is yours stays yours. Furthermore we do our best to secure our systems as best as we can.
-landingpage.alpha.guarantees.data-safety.title=Safety of account data
-landingpage.alpha.guarantees.intro=Even though the Smederee is considered alpha we provide the following guarantees:
-landingpage.alpha.guarantees.title=Guarantees during alpha
-landingpage.alpha.image.alt=A construction worker working on a roof structure high above a city seen in the background.
-landingpage.alpha.intro=Although we are aiming for high quality software an alpha implies several rough edges which we will explain a bit on this page.
-landingpage.alpha.issues.changes.text=As we are developing and testing our product we might run into situations where bigger changes to existing features are necessary. Nonetheless we try to reduce these to a minimum.
-landingpage.alpha.issues.changes.title=Existing services might be subject to change
-landingpage.alpha.issues.incomplete.text=Several features are not yet available.
-landingpage.alpha.issues.incomplete.title=Incomplete feature set
-landingpage.alpha.issues.intro=As mentioned some issues are to be expected, most notably:
-landingpage.alpha.issues.payment.text=Once we reach the phase in which the project stabilises and can be considered production ready all users who maintain (host) their own project(s) here are expected to pay. This does not include contributors who only create an account to contribute to projects hosted on the Smederee. However there is no payment plan yet and also we do not plan to price out users that are unable to pay. We will communicate these changes in advance and try to reach a feasible solution. Payment functionality will be rolled out and tested after we leave the alpha and enter the beta phase.
-landingpage.alpha.issues.payment.title=Payment will be required eventually.
-landingpage.alpha.issues.title=Issues to expect
-landingpage.alpha.title=Details about the public alpha phase of Smederee.
-
-landingpage.contact.title=Contact
-landingpage.contact.intro=Currently our preferred channel of communication is via email.
-landingpage.contact.email.title=E-Mail
-#landingpage.contact.email.text=If you have feedback and questions, please don''t hesitate to ready out to us either via our official mailing list: {0} or directly (and in private) via email: {1}.
-landingpage.contact.email.text=If you have feedback and questions, please don''t hesitate to ready out to us directly (and in private) via email: {1}. We will have a mailing list in the future but currently this is work in progress.
-landingpage.contact.abuse.title=Abuse
-landingpage.contact.abuse.text=In the case of noticing content that you deem to be removed, please write an email to {0} and describe what content and why you think it should be removed.
-
-landingpage.imprint.title=Imprint / Impressum
-
-landingpage.index.footer.alpha.title=Public alpha phase
-landingpage.index.footer.alpha.text=We are currently running the Smederee as a public alpha service. This means that some functionality is incomplete or not yet implemented. Also existing services might be subject to change.
-landingpage.index.footer.alpha.link-text=Read more details about what to expect while using the Smederee.
-landingpage.index.footer.services.link-text=Our Services
-landingpage.index.footer.services.title=Software Consulting and Development
-landingpage.index.footer.services.text=Hire our experienced engineers to deliver solutions to your problems. We are used to solving hard problems and working across a multitude of business domains. While bringing in knowledge from our field of expertise we provide mentoring to increase the potential of your teams.
-landingpage.index.getstarted.title=Get started today
-landingpage.index.menu.heading=Smederee
-landingpage.index.pitch.header.first=Code Hosting
-landingpage.index.pitch.header.fourth=Seamless CI/CD
-landingpage.index.pitch.header.second=Issue Tracking
-landingpage.index.pitch.header.third=Code Review
-landingpage.index.pitch.teaser.first=Hosted code repositories with fine grained access control including read only access for users without an account.
-landingpage.index.pitch.teaser.fourth=Coming soon...
-landingpage.index.pitch.teaser.second=Enjoy powerful and focussed issue tracking and project management which seamlessly integrates with email and mailing lists.
-landingpage.index.pitch.teaser.third=Review patches the way it was meant to be by leveraging the power of email without missing out on a web based interface.
-landingpage.index.pitch.title=Smederee brings teams together to build solid software!
-landingpage.index.ribbon.text=Benefit from our experience with building software for decades and leverage efficient engineering tools to craft your own software. All of your data is protected by the EU GDPR regulation, so what is yours stays yours. No tracking, no third party cookies and no dependencies on external services!
-landingpage.index.ribbon.title=We help you to craft great tools!
-landingpage.index.splash.text=Leverage the power of the <a href="http://darcs.net" target="_blank">darcs</a> vcs to handle your projects with ease and confidence and rest assured that we won''t track you or sell your data!
-landingpage.index.splash.title=Craft Software!
-
-landingpage.privacy-policy.title=Privacy Policy
-landingpage.privacy-policy.intro=We are not collecting any user data aside from essential data required to keep the service running.
-landingpage.privacy-policy.backups.text=In the case of deleting your account, data in our rotating off-line backup files may continue to exist for up to 30 days according to GDPR guidelines.
-landingpage.privacy-policy.backups.title=Backups
-landingpage.privacy-policy.contributions.text=For all contributions done by you (dear user), you have full responsibility and control to add, modify, create this data. This includes for example the code you commit, content, repositories and your account settings and details. If some data cannot be modified by users, this is considered a technical bug that needs to be fixed. Please report such issues.
-landingpage.privacy-policy.contributions.title=User contributions
-landingpage.privacy-policy.ip-addresses.text=Server log files can contain IP addresses and user agent strings from connecting computers. These log files are deleted automatically within in 30 days.
-landingpage.privacy-policy.ip-addresses.title=IP addresses / Log files
-landingpage.privacy-policy.sources.text=This text was heavily inspired by the privacy policy of Codeberg.
-landingpage.privacy-policy.sources.title=Sources
-
-landingpage.terms-of-use.title=Terms of Use
-
-landingpage.welcome.image.alt=A neon sign saying: Do something great!
-landingpage.welcome.ribbon.text=Welcome to the smederee! You can now use your credentials to login and start creating. Please note that some functionalities are locked until you have validated your email address.
-landingpage.welcome.ribbon.title=Thank you and welcome!
-landingpage.welcome.title=Welcome to the Smederee!
-
-# Repository pages
-repositories.all.column.description=Description
-repositories.all.column.name=Name
-repositories.all.none-found=No repositories found.
-
-repositories.yours.column.description=Description
-repositories.yours.column.name=Name
-repositories.yours.none-found=Looks like you don''t have any repositories created yet.
-
-repository.changes.patch.description=Showing details for patch {0}.
-repository.changes.patch.title.link=Show details for patch {0}.
-repository.changes.patch.summary.title=Summary of changes
-repository.changes.patch.summary.added={0} files added
-repository.changes.patch.summary.modified={0} files modified with {1} lines added and {2} lines removed
-repository.changes.patch.summary.modified.details={0} with {1} added and {2} removed lines
-repository.changes.patch.summary.removed={0} files removed
-repository.changes.description=Showing {0} changes starting from {1}.
-repository.changes.description.empty=There are no recorded patches yet.
-
-repository.delete.title=Here you can delete your repository if you want to.
-
-repository.edit.title=Edit the repository settings.
-
-repository.label.edit.title=Edit label >> {0} <<
-repository.label.edit.link=Edit
-repository.labels.add.title=Add a new label.
-repository.labels.edit.title=Manage your repository labels.
-repository.labels.view.title=Repository labels
-repository.labels.list.empty=There are no labels defined.
-repository.labels.list.title={0} labels.
-
-repository.menu.changes.next=Next
-repository.menu.changes=Changes
-repository.menu.delete=Delete
-repository.menu.edit=Edit
-repository.menu.files=Files
-repository.menu.labels=Labels
-repository.menu.milestones=Milestones
-repository.menu.overview=Overview
-repository.menu.website=Website
-repository.menu.website.tooltip=Click here to open the project website ({0}) in a new tab or window.
-
-repository.milestone.edit.title=Edit milestone >> {0} <<
-repository.milestone.edit.link=Edit
-repository.milestone.title.date=({0,date,yyyy-MM-dd (E)})
-repository.milestones.add.title=Add a new milestone.
-repository.milestones.edit.title=Manage your repository milestones.
-repository.milestones.view.title=Repository milestones
-repository.milestones.list.empty=There are no milestones defined.
-repository.milestones.list.title={0} milestones.
-
-repository.description.title=Summary:
-repository.description.forked-from=Forked from:
-
-repository.overview.clone.fork=Create your personal fork.
-repository.overview.clone.title=Clone this repository
-repository.overview.clone.read-only=read-only
-repository.overview.clone.read-write=read-write
-repository.overview.clone.read-write.help=Please note that you might need to set the DARCS_SCP="scp -O" environment variable on some systems if you get "subsystem request failed" errors during darcs ssh operations.
-repository.overview.clone.read-write.owners-only=Currently only the owner of a repository can use it via ssh.
-repository.overview.download.title=Downloads
-repository.overview.download.link=Source code (.tar.gz)
-repository.overview.latest-changes=Latest changes
-repository.overview.latest-changes.timestamp={0,date,yyyy-MM-dd (E)}, {0,time,short}
-
-# User management / settings
-user.settings.account.delete.title=Delete your account
-user.settings.account.description=On this page you can manage your basic account settings and validate or delete your account.
-user.settings.account.title=Account
-user.settings.account.validate-email.title=Validate your email address
-user.settings.ssh.add.title=Add a new public ssh key.
-user.settings.ssh.description=Here you can manage your SSH keys.
-user.settings.ssh.key.created=Uploaded on {0,date,yyyy-MM-dd (E)}
-user.settings.ssh.key.last-used=Last used on {0,date,yyyy-MM-dd (E)}
-user.settings.ssh.list.title=Manage your existing ssh keys.
-user.settings.ssh.list.empty=You haven''t uploaded any ssh keys yet.
-user.settings.ssh.title=SSH-Keys
-user.settings.title=Settings
diff -rN -u old-smederee/modules/hub/src/main/resources/messages.properties new-smederee/modules/hub/src/main/resources/messages.properties
--- old-smederee/modules/hub/src/main/resources/messages.properties	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/messages.properties	2025-01-31 07:57:26.142415137 +0000
@@ -0,0 +1,266 @@
+# Smederee - Hub
+# Resource bundle for translations.
+#
+# *** FILE ENCODING MUST BE UTF-8! ***
+#
+# File structure
+# ==============
+# 1. Translation keys are supposed to be grouped by semantic topic (e.g. 
+#    error.foo.bar or ui.button.logout).
+# 2. Grouping continues downward if it makes sense, e.g.
+#    error.forbidden.title, error.forbidden.message.
+#
+errors.account.not-validated=Sorry, but your account has not been validated and is therefore not allowed to perform the desired action. Please validate your account. You can do so on the settings page.
+errors.forbidden.title=403 - Forbidden
+
+# Forms
+form.account.delete.button.submit=Delete my account!
+form.account.delete.i-am-sure=Yes, I am sure that I want to delete my account and all related data!
+form.account.delete.notice=If you delete your account then all related data will be permanently removed. This action CANNOT be undone!
+form.account.delete.password=Password
+form.account.language.button.submit=Save language settings.
+form.account.language=Language
+form.account.language.help=The language settings will affect the translation and display of date and numbers.
+form.account.validate-email.notice=You have not yet validated your email address, therefore some operations are not yet allowed. If you have not received a validation email from us, please use the button below to send the email.
+form.account.validate-email.button.submit=Send validation email
+form.create-repo.button.submit=Create repository
+form.create-repo.name=Name
+form.create-repo.name.placeholder=Please enter a repository name.
+form.create-repo.name.help=A repository name must start with a letter or number and must contain only alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long.
+form.create-repo.is-private=Private Repository
+form.create-repo.is-private.help=A private repository can only be accessed by the owner and accounts which have been given permissions to do so.
+form.create-repo.description=Description
+form.create-repo.description.placeholder=
+form.create-repo.description.help=An optional short description of you repo / project.
+form.create-repo.website=Website
+form.create-repo.website.placeholder=https://example.com
+form.create-repo.website.help=An optional URI pointing to the website of your project.
+form.repository.delete.button.submit=Delete repository
+form.repository.delete.i-am-sure=Yes, I am sure that I want to delete the repository {0}!
+form.repository.delete.notice=This action CANNOT be undone! Please be careful.
+form.repository.delete.title=Delete repository {0}
+form.edit-repo.button.submit=Edit repository
+form.edit-repo.name=Name
+form.edit-repo.name.help=A repository name must start with a letter or number and must contain only alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long.
+form.edit-repo.is-private=Private Repository
+form.edit-repo.is-private.help=A private repository can only be accessed by the owner and accounts which have been given permissions to do so.
+form.edit-repo.description=Description
+form.edit-repo.description.help=An optional short description of you repo / project.
+form.edit-repo.website=Website
+form.edit-repo.website.placeholder=https://example.com
+form.edit-repo.website.help=An optional URI pointing to the website of your project.
+form.fork.button.submit=Clone to your account.
+form.label.create.button.submit=Create label
+form.label.colour=Colour
+form.label.colour.help=Pick a colour which will be used as background colour for the label.
+form.label.description=Description
+form.label.description.help=The description is optional and may contain up to 254 characters.
+form.label.description.placeholder=description
+form.label.name=Name
+form.label.name.help=A label name can be up to 40 characters long, must not be empty and must be unique in the scope of a project.
+form.label.name.placeholder=label name
+form.label.edit.button.submit=Save label
+form.label.delete.button.submit=Delete
+form.label.delete.i-am-sure=Yes, I'm sure!
+form.login.button.submit=Login
+form.login.password=Password
+form.login.password.placeholder=Please enter your password here.
+form.login.username=Username
+form.login.username.placeholder=Please enter your username.
+form.milestone.create.button.submit=Create milestone
+form.milestone.due-date=Due date
+form.milestone.due-date.help=You may pick an optional date to indicate until when the milestone is planned to be reached.
+form.milestone.description=Description
+form.milestone.description.help=An optional description of the milestone.
+form.milestone.description.placeholder=description
+form.milestone.title=Title
+form.milestone.title.help=A milestone title can be up to 64 characters long, must not be empty and must be unique in the scope of a project.
+form.milestone.title.placeholder=milestone title
+form.milestone.edit.button.submit=Save milestone
+form.milestone.delete.button.submit=Delete
+form.milestone.delete.i-am-sure=Yes, I'm sure!
+form.signup.button.submit=Sign up for an account
+form.signup.email=Email address
+form.signup.email.help=Please enter your email address.
+form.signup.email.placeholder=someone@somewhere.org
+form.signup.password=Password
+form.signup.password.help=Your password must be at least 12 characters long.
+form.signup.password.placeholder=Please choose a secure password!
+form.signup.username=Username
+form.signup.username.help=A username is required because it will be used to group your projects and other stuff. It must be between 2 and 31 characters long and contain only lowercase alphanumeric characters and start with a character (letter).
+form.signup.username.placeholder=Please choose your username.
+form.ssh.add.button.reset=Cancel
+form.ssh.add.button.submit=Add SSH key
+form.ssh.add.key=Key content
+form.ssh.add.key.help=Please note that we currently only support RSA keys!
+form.ssh.add.key.placeholder=The content of your key file (usually located in ~/.ssh/id_rsa.pub) starting with ssh-rsa or ssh-ed25519.
+form.ssh.add.name=Name
+form.ssh.add.name.help=If left empty it will be taken from the ssh key comment (if that exists).
+form.ssh.delete.button.submit=Delete key
+form.ssh.delete.i-am-sure=Yes, I'm sure!
+
+# Global / generic translations
+global.alpha=Alpha Notice
+global.contact=Contact
+global.copyright=© 2022 Wegtam GmbH
+global.error=Error
+global.imprint=Imprint/Impressum
+global.login=Login
+global.logout=Logout
+global.navbar.top.logo=Smederee
+global.navbar.top.repositories.all=All repositories
+global.navbar.top.repositories.yours=Your repositories
+global.navbar.top.repository.new=New repository
+global.navbar.top.settings=Settings
+global.privacy=Privacy Policy
+global.signup=Sign Up
+global.terms.of.use=Terms of Use
+
+# Landing Pages
+landingpage.alpha.future-goals.intro=We intend to develop the Smederee into a production ready project during the alpha phase. Afterwards we''ll phase over into a beta phase during which remaining bugs are squashed and the payment system will be introduced.
+landingpage.alpha.future-goals.title=Goals for the alpha phase.
+landingpage.alpha.guarantees.data-safety.text=Frequent backups are performed and everything is stored on servers inside the EU which implies that it is protected by the EU GDPR regulation, so what is yours stays yours. Furthermore we do our best to secure our systems as best as we can.
+landingpage.alpha.guarantees.data-safety.title=Safety of account data
+landingpage.alpha.guarantees.intro=Even though the Smederee is considered alpha we provide the following guarantees:
+landingpage.alpha.guarantees.title=Guarantees during alpha
+landingpage.alpha.image.alt=A construction worker working on a roof structure high above a city seen in the background.
+landingpage.alpha.intro=Although we are aiming for high quality software an alpha implies several rough edges which we will explain a bit on this page.
+landingpage.alpha.issues.changes.text=As we are developing and testing our product we might run into situations where bigger changes to existing features are necessary. Nonetheless we try to reduce these to a minimum.
+landingpage.alpha.issues.changes.title=Existing services might be subject to change
+landingpage.alpha.issues.incomplete.text=Several features are not yet available.
+landingpage.alpha.issues.incomplete.title=Incomplete feature set
+landingpage.alpha.issues.intro=As mentioned some issues are to be expected, most notably:
+landingpage.alpha.issues.payment.text=Once we reach the phase in which the project stabilises and can be considered production ready all users who maintain (host) their own project(s) here are expected to pay. This does not include contributors who only create an account to contribute to projects hosted on the Smederee. However there is no payment plan yet and also we do not plan to price out users that are unable to pay. We will communicate these changes in advance and try to reach a feasible solution. Payment functionality will be rolled out and tested after we leave the alpha and enter the beta phase.
+landingpage.alpha.issues.payment.title=Payment will be required eventually.
+landingpage.alpha.issues.title=Issues to expect
+landingpage.alpha.title=Details about the public alpha phase of Smederee.
+
+landingpage.contact.title=Contact
+landingpage.contact.intro=Currently our preferred channel of communication is via email.
+landingpage.contact.email.title=E-Mail
+#landingpage.contact.email.text=If you have feedback and questions, please don''t hesitate to ready out to us either via our official mailing list: {0} or directly (and in private) via email: {1}.
+landingpage.contact.email.text=If you have feedback and questions, please don''t hesitate to ready out to us directly (and in private) via email: {1}. We will have a mailing list in the future but currently this is work in progress.
+landingpage.contact.abuse.title=Abuse
+landingpage.contact.abuse.text=In the case of noticing content that you deem to be removed, please write an email to {0} and describe what content and why you think it should be removed.
+
+landingpage.imprint.title=Imprint / Impressum
+
+landingpage.index.footer.alpha.title=Public alpha phase
+landingpage.index.footer.alpha.text=We are currently running the Smederee as a public alpha service. This means that some functionality is incomplete or not yet implemented. Also existing services might be subject to change.
+landingpage.index.footer.alpha.link-text=Read more details about what to expect while using the Smederee.
+landingpage.index.footer.services.link-text=Our Services
+landingpage.index.footer.services.title=Software Consulting and Development
+landingpage.index.footer.services.text=Hire our experienced engineers to deliver solutions to your problems. We are used to solving hard problems and working across a multitude of business domains. While bringing in knowledge from our field of expertise we provide mentoring to increase the potential of your teams.
+landingpage.index.getstarted.title=Get started today
+landingpage.index.menu.heading=Smederee
+landingpage.index.pitch.header.first=Code Hosting
+landingpage.index.pitch.header.fourth=Seamless CI/CD
+landingpage.index.pitch.header.second=Issue Tracking
+landingpage.index.pitch.header.third=Code Review
+landingpage.index.pitch.teaser.first=Hosted code repositories with fine grained access control including read only access for users without an account.
+landingpage.index.pitch.teaser.fourth=Coming soon...
+landingpage.index.pitch.teaser.second=Enjoy powerful and focussed issue tracking and project management which seamlessly integrates with email and mailing lists.
+landingpage.index.pitch.teaser.third=Review patches the way it was meant to be by leveraging the power of email without missing out on a web based interface.
+landingpage.index.pitch.title=Smederee brings teams together to build solid software!
+landingpage.index.ribbon.text=Benefit from our experience with building software for decades and leverage efficient engineering tools to craft your own software. All of your data is protected by the EU GDPR regulation, so what is yours stays yours. No tracking, no third party cookies and no dependencies on external services!
+landingpage.index.ribbon.title=We help you to craft great tools!
+landingpage.index.splash.text=Leverage the power of the <a href="http://darcs.net" target="_blank">darcs</a> vcs to handle your projects with ease and confidence and rest assured that we won''t track you or sell your data!
+landingpage.index.splash.title=Craft Software!
+
+landingpage.privacy-policy.title=Privacy Policy
+landingpage.privacy-policy.intro=We are not collecting any user data aside from essential data required to keep the service running.
+landingpage.privacy-policy.backups.text=In the case of deleting your account, data in our rotating off-line backup files may continue to exist for up to 30 days according to GDPR guidelines.
+landingpage.privacy-policy.backups.title=Backups
+landingpage.privacy-policy.contributions.text=For all contributions done by you (dear user), you have full responsibility and control to add, modify, create this data. This includes for example the code you commit, content, repositories and your account settings and details. If some data cannot be modified by users, this is considered a technical bug that needs to be fixed. Please report such issues.
+landingpage.privacy-policy.contributions.title=User contributions
+landingpage.privacy-policy.ip-addresses.text=Server log files can contain IP addresses and user agent strings from connecting computers. These log files are deleted automatically within in 30 days.
+landingpage.privacy-policy.ip-addresses.title=IP addresses / Log files
+landingpage.privacy-policy.sources.text=This text was heavily inspired by the privacy policy of Codeberg.
+landingpage.privacy-policy.sources.title=Sources
+
+landingpage.terms-of-use.title=Terms of Use
+
+landingpage.welcome.image.alt=A neon sign saying: Do something great!
+landingpage.welcome.ribbon.text=Welcome to the smederee! You can now use your credentials to login and start creating. Please note that some functionalities are locked until you have validated your email address.
+landingpage.welcome.ribbon.title=Thank you and welcome!
+landingpage.welcome.title=Welcome to the Smederee!
+
+# Repository pages
+repositories.all.column.description=Description
+repositories.all.column.name=Name
+repositories.all.none-found=No repositories found.
+
+repositories.yours.column.description=Description
+repositories.yours.column.name=Name
+repositories.yours.none-found=Looks like you don''t have any repositories created yet.
+
+repository.changes.patch.description=Showing details for patch {0}.
+repository.changes.patch.title.link=Show details for patch {0}.
+repository.changes.patch.summary.title=Summary of changes
+repository.changes.patch.summary.added={0} files added
+repository.changes.patch.summary.modified={0} files modified with {1} lines added and {2} lines removed
+repository.changes.patch.summary.modified.details={0} with {1} added and {2} removed lines
+repository.changes.patch.summary.removed={0} files removed
+repository.changes.description=Showing {0} changes starting from {1}.
+repository.changes.description.empty=There are no recorded patches yet.
+
+repository.delete.title=Here you can delete your repository if you want to.
+
+repository.edit.title=Edit the repository settings.
+
+repository.label.edit.title=Edit label >> {0} <<
+repository.label.edit.link=Edit
+repository.labels.add.title=Add a new label.
+repository.labels.edit.title=Manage your repository labels.
+repository.labels.view.title=Repository labels
+repository.labels.list.empty=There are no labels defined.
+repository.labels.list.title={0} labels.
+
+repository.menu.changes.next=Next
+repository.menu.changes=Changes
+repository.menu.delete=Delete
+repository.menu.edit=Edit
+repository.menu.files=Files
+repository.menu.labels=Labels
+repository.menu.milestones=Milestones
+repository.menu.overview=Overview
+repository.menu.website=Website
+repository.menu.website.tooltip=Click here to open the project website ({0}) in a new tab or window.
+
+repository.milestone.edit.title=Edit milestone >> {0} <<
+repository.milestone.edit.link=Edit
+repository.milestone.title.date=({0,date,yyyy-MM-dd (E)})
+repository.milestones.add.title=Add a new milestone.
+repository.milestones.edit.title=Manage your repository milestones.
+repository.milestones.view.title=Repository milestones
+repository.milestones.list.empty=There are no milestones defined.
+repository.milestones.list.title={0} milestones.
+
+repository.description.title=Summary:
+repository.description.forked-from=Forked from:
+
+repository.overview.clone.fork=Create your personal fork.
+repository.overview.clone.title=Clone this repository
+repository.overview.clone.read-only=read-only
+repository.overview.clone.read-write=read-write
+repository.overview.clone.read-write.help=Please note that you might need to set the DARCS_SCP="scp -O" environment variable on some systems if you get "subsystem request failed" errors during darcs ssh operations.
+repository.overview.clone.read-write.owners-only=Currently only the owner of a repository can use it via ssh.
+repository.overview.download.title=Downloads
+repository.overview.download.link=Source code (.tar.gz)
+repository.overview.latest-changes=Latest changes
+repository.overview.latest-changes.timestamp={0,date,yyyy-MM-dd (E)}, {0,time,short}
+
+# User management / settings
+user.settings.account.delete.title=Delete your account
+user.settings.account.description=On this page you can manage your basic account settings and validate or delete your account.
+user.settings.account.title=Account
+user.settings.account.validate-email.title=Validate your email address
+user.settings.language.title=You preferred language.
+user.settings.ssh.add.title=Add a new public ssh key.
+user.settings.ssh.description=Here you can manage your SSH keys.
+user.settings.ssh.key.created=Uploaded on {0,date,yyyy-MM-dd (E)}
+user.settings.ssh.key.last-used=Last used on {0,date,yyyy-MM-dd (E)}
+user.settings.ssh.list.title=Manage your existing ssh keys.
+user.settings.ssh.list.empty=You haven''t uploaded any ssh keys yet.
+user.settings.ssh.title=SSH-Keys
+user.settings.title=Settings
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala	2025-01-31 07:57:26.142415137 +0000
@@ -22,6 +22,7 @@
 import de.smederee.security._
 import de.smederee.ssh.PublicSshKey
 import fs2.Stream
+import de.smederee.i18n.LanguageCode
 
 /** The base class for database operations related to account management for users.
   *
@@ -104,6 +105,17 @@
     */
   def markAsValidated(uid: UserId): F[Int]
 
+  /** Set the language for the user account.
+    *
+    * @param uid
+    *   The unique id of the user account.
+    * @param language
+    *   An option to the preferred language that shall be set for the account.
+    * @return
+    *   The number of affected database rows.
+    */
+  def setLanguage(uid: UserId, language: Option[LanguageCode]): F[Int]
+
   /** Set the validation token for the account with the given user id.
     *
     * @param uid
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	2025-01-31 07:57:26.142415137 +0000
@@ -31,6 +31,7 @@
 import de.smederee.hub.RequestHelpers.instances.given
 import de.smederee.hub.config._
 import de.smederee.hub.forms.types._
+import de.smederee.i18n.LanguageCode
 import de.smederee.security._
 import de.smederee.ssh._
 import org.http4s._
@@ -122,7 +123,8 @@
     case ar @ POST -> Root / "user" / "settings" / "ssh" / "add" as user =>
       ar.req.decodeStrict[F, UrlForm] { urlForm =>
         for {
-          csrf <- Sync[F].delay(ar.req.getCsrfToken)
+          csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+          language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
           formData <- Sync[F].delay {
             urlForm.values.map { t =>
               val (key, values) = t
@@ -140,12 +142,13 @@
           resp <- form match {
             case Validated.Invalid(errors) =>
               BadRequest(
-                views.html.account.sshSettings()(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)(
-                  actionBaseUri,
-                  addAction,
-                  deleteAction,
-                  keys
-                )(formData, FormErrors.fromNec(errors))
+                views.html.account
+                  .sshSettings(lang = language)(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)(
+                    actionBaseUri,
+                    addAction,
+                    deleteAction,
+                    keys
+                  )(formData, FormErrors.fromNec(errors))
               )
             case Validated.Valid(validSshKeyForm) =>
               for {
@@ -160,19 +163,20 @@
                   case None =>
                     // There has been no write at all, implying that the conversion failed.
                     BadRequest(
-                      views.html.account.sshSettings()(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)(
-                        actionBaseUri,
-                        addAction,
-                        deleteAction,
-                        keys
-                      )(
-                        formData,
-                        Map(
-                          AddPublicSshKeyForm.fieldGlobal -> List(
-                            FormFieldError("The key could not be properly converted!")
+                      views.html.account
+                        .sshSettings(lang = language)(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)(
+                          actionBaseUri,
+                          addAction,
+                          deleteAction,
+                          keys
+                        )(
+                          formData,
+                          Map(
+                            AddPublicSshKeyForm.fieldGlobal -> List(
+                              FormFieldError("The key could not be properly converted!")
+                            )
                           )
                         )
-                      )
                     )
                   case Some(1) =>
                     // One row was written to the database implying that everything went well.
@@ -181,19 +185,20 @@
                     // Any other result implies that there has been an error.
                     accountManagementRepo.listSshKeys(user.uid).compile.toList.flatMap { keys =>
                       BadRequest(
-                        views.html.account.sshSettings()(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)(
-                          actionBaseUri,
-                          addAction,
-                          deleteAction,
-                          keys
-                        )(
-                          formData,
-                          Map(
-                            AddPublicSshKeyForm.fieldGlobal -> List(
-                              FormFieldError("An error occured while saving the key to the database!")
+                        views.html.account
+                          .sshSettings(lang = language)(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)(
+                            actionBaseUri,
+                            addAction,
+                            deleteAction,
+                            keys
+                          )(
+                            formData,
+                            Map(
+                              AddPublicSshKeyForm.fieldGlobal -> List(
+                                FormFieldError("An error occured while saving the key to the database!")
+                              )
                             )
                           )
-                        )
                       )
                     }
                 }
@@ -207,7 +212,8 @@
     case ar @ POST -> Root / "user" / "settings" / "delete" as user =>
       ar.req.decodeStrict[F, UrlForm] { urlForm =>
         for {
-          csrf <- Sync[F].delay(ar.req.getCsrfToken)
+          csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+          language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
           formData <- Sync[F].delay {
             urlForm.values.map { t =>
               val (key, values) = t
@@ -217,11 +223,12 @@
               ) // Pick the first value (a field might get submitted multiple times)!
             }
           }
-          passwordField <- Sync[F].delay(formData.get("password").flatMap(Password.from))
-          userIsSure    <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes"))
-          rootUri       <- Sync[F].delay(configuration.external.createFullUri(uri"/"))
-          actionBaseUri <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings"))
-          deleteAction  <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete"))
+          passwordField  <- Sync[F].delay(formData.get("password").flatMap(Password.from))
+          userIsSure     <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes"))
+          rootUri        <- Sync[F].delay(configuration.external.createFullUri(uri"/"))
+          actionBaseUri  <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings"))
+          deleteAction   <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete"))
+          languageAction <- Sync[F].delay(configuration.external.createFullUri(uri"/user/settings/language"))
           validateAction <- Sync[F].delay(
             configuration.external.createFullUri(uri"user/settings/email/validate")
           )
@@ -253,9 +260,10 @@
               } yield response
             } else
               BadRequest(
-                views.html.account.settings()(csrf, Option(s"Smederee/~${user.name} - Settings"), user)(
+                views.html.account.settings(lang = language)(csrf, Option(s"Smederee/~${user.name} - Settings"), user)(
                   actionBaseUri,
                   deleteAction,
+                  languageAction,
                   validateAction
                 )
               )
@@ -300,13 +308,14 @@
   private val sendValidationEmail: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ POST -> Root / "user" / "settings" / "email" / "validate" as user =>
       for {
-        csrf    <- Sync[F].delay(ar.req.getCsrfToken)
-        token   <- Sync[F].delay(ValidationToken.generate)
-        _       <- accountManagementRepo.setValidationToken(user.uid, token)
-        from    <- Sync[F].delay(FromAddress("noreply@smeder.ee"))
-        to      <- Sync[F].delay(NonEmptyList.of(user.email.toToAddress))
-        uri     <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/email/validate"))
-        subject <- Sync[F].delay(SubjectLine("Smederee - Please validate your email address."))
+        csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+        language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+        token    <- Sync[F].delay(ValidationToken.generate)
+        _        <- accountManagementRepo.setValidationToken(user.uid, token)
+        from     <- Sync[F].delay(FromAddress("noreply@smeder.ee"))
+        to       <- Sync[F].delay(NonEmptyList.of(user.email.toToAddress))
+        uri      <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/email/validate"))
+        subject  <- Sync[F].delay(SubjectLine("Smederee - Please validate your email address."))
         body <- Sync[F].delay(
           TextBody(views.txt.emails.validate(user, token, uri).toString)
         ) // TODO: extension method?
@@ -319,19 +328,50 @@
       } yield response
   }
 
+  private val setLanguage: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / "user" / "settings" / "language" as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf <- Sync[F].delay(ar.req.getCsrfToken)
+          formData <- Sync[F].delay {
+            urlForm.values.map { t =>
+              val (key, values) = t
+              (
+                key,
+                values.headOption.getOrElse("")
+              ) // Pick the first value (a field might get submitted multiple times)!
+            }
+          }
+          languageCode   <- Sync[F].delay(formData.get("language").flatMap(LanguageCode.from))
+          rootUri        <- Sync[F].delay(configuration.external.createFullUri(uri"/"))
+          actionBaseUri  <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings"))
+          deleteAction   <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete"))
+          languageAction <- Sync[F].delay(configuration.external.createFullUri(uri"/user/settings/language"))
+          validateAction <- Sync[F].delay(
+            configuration.external.createFullUri(uri"user/settings/email/validate")
+          )
+          _    <- accountManagementRepo.setLanguage(user.uid, languageCode)
+          resp <- SeeOther(Location(actionBaseUri))
+        } yield resp
+      }
+  }
+
   private val showAccountSettings: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ GET -> Root / "user" / "settings" as user =>
       for {
-        csrf          <- Sync[F].delay(ar.req.getCsrfToken)
-        actionBaseUri <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings"))
-        deleteAction  <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete"))
+        csrf           <- Sync[F].delay(ar.req.getCsrfToken)
+        language       <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+        actionBaseUri  <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings"))
+        deleteAction   <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete"))
+        languageAction <- Sync[F].delay(configuration.external.createFullUri(uri"/user/settings/language"))
         validateAction <- Sync[F].delay(
           configuration.external.createFullUri(uri"user/settings/email/validate")
         )
         resp <- Ok(
-          views.html.account.settings()(csrf, Option(s"Smederee/~${user.name} - Settings"), user)(
+          views.html.account.settings(lang = language)(csrf, Option(s"Smederee/~${user.name} - Settings"), user)(
             actionBaseUri,
             deleteAction,
+            languageAction,
             validateAction
           )
         )
@@ -342,12 +382,13 @@
     case ar @ GET -> Root / "user" / "settings" / "ssh" as user =>
       for {
         csrf          <- Sync[F].delay(ar.req.getCsrfToken)
+        language      <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
         actionBaseUri <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings"))
         addAction     <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/ssh/add"))
         deleteAction  <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/ssh/delete"))
         keys          <- accountManagementRepo.listSshKeys(user.uid).compile.toList
         resp <- Ok(
-          views.html.account.sshSettings()(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)(
+          views.html.account.sshSettings(lang = language)(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)(
             actionBaseUri,
             addAction,
             deleteAction,
@@ -370,7 +411,7 @@
   }
 
   val protectedRoutes =
-    addSshKey <+> deleteAccount <+> deleteSshKey <+> sendValidationEmail <+> showAccountSettings <+> showAccountSshSettings
+    addSshKey <+> deleteAccount <+> deleteSshKey <+> sendValidationEmail <+> setLanguage <+> showAccountSettings <+> showAccountSshSettings
 
   val routes = validateEmailAddress
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala	2025-01-31 07:57:26.142415137 +0000
@@ -22,6 +22,7 @@
 import cats._
 import cats.syntax.all._
 import de.smederee.email.EmailAddress
+import de.smederee.i18n.LanguageCode
 import de.smederee.security._
 import de.smederee.tickets.ProjectOwner
 import org.springframework.security.crypto.argon2.Argon2PasswordEncoder
@@ -205,8 +206,16 @@
   *   The email address of the user.
   * @param validatedEmail
   *   This flag indicates if the email address of the user has been validated via a validation email.
+  * @param language
+  *   The language code of the users preferred language.
   */
-final case class Account(uid: UserId, name: Username, email: EmailAddress, validatedEmail: Boolean)
+final case class Account(
+    uid: UserId,
+    name: Username,
+    email: EmailAddress,
+    validatedEmail: Boolean,
+    language: Option[LanguageCode]
+)
 
 object Account {
   given Eq[Account] =
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala	2025-01-31 07:57:26.142415137 +0000
@@ -21,6 +21,7 @@
 
 import cats.effect._
 import de.smederee.email.EmailAddress
+import de.smederee.i18n.LanguageCode
 import de.smederee.security._
 import de.smederee.ssh._
 import doobie._
@@ -33,13 +34,14 @@
   given Meta[EncodedKeyBytes] = Meta[String].timap(EncodedKeyBytes.unsafeFrom)(_.toString)
   given Meta[KeyComment]      = Meta[String].timap(KeyComment.apply)(_.toString)
   given Meta[KeyFingerprint]  = Meta[String].timap(KeyFingerprint.apply)(_.toString)
+  given Meta[LanguageCode]    = Meta[String].timap(LanguageCode.apply)(_.toString)
   given Meta[PasswordHash]    = Meta[String].timap(PasswordHash.apply)(_.toString)
   given Meta[SshKeyType]      = Meta[String].timap(id => SshKeyType.Mappings(id))(_.identifier)
   given Meta[UserId]          = Meta[UUID].timap(UserId.apply)(_.toUUID)
   given Meta[Username]        = Meta[String].timap(Username.apply)(_.toString)
   given Meta[ValidationToken] = Meta[String].timap(ValidationToken.apply)(_.toString)
 
-  private val selectAccountColumns = fr"""SELECT uid, name, email, validated_email FROM "hub"."accounts""""
+  private val selectAccountColumns = fr"""SELECT uid, name, email, validated_email, language FROM "hub"."accounts""""
 
   override def addSshKey(key: PublicSshKey): F[Int] =
     sql"""INSERT INTO "hub"."ssh_keys" (id, uid, key_type, key, fingerprint, comment, created_at)
@@ -70,6 +72,9 @@
     sql"""UPDATE "hub"."accounts" SET validated_email = TRUE, validation_token = NULL WHERE uid = $uid""".update.run
       .transact(tx)
 
+  override def setLanguage(uid: UserId, language: Option[LanguageCode]): F[Int] =
+    sql"""UPDATE "hub"."accounts" SET language = $language WHERE uid = $uid""".update.run.transact(tx)
+
   override def setValidationToken(uid: UserId, token: ValidationToken): F[Int] =
     sql"""UPDATE "hub"."accounts" SET validation_token = $token WHERE uid = $uid""".update.run.transact(tx)
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala	2025-01-31 07:57:26.142415137 +0000
@@ -21,14 +21,16 @@
 
 import cats.effect._
 import de.smederee.email.EmailAddress
+import de.smederee.i18n.LanguageCode
 import de.smederee.security._
-import doobie._
 import doobie.Fragments._
+import doobie._
 import doobie.implicits._
 import doobie.postgres.implicits._
 
 final class DoobieAuthenticationRepository[F[_]: Sync](tx: Transactor[F]) extends AuthenticationRepository[F] {
   given Meta[EmailAddress] = Meta[String].timap(EmailAddress.apply)(_.toString)
+  given Meta[LanguageCode] = Meta[String].timap(LanguageCode.apply)(_.toString)
   given Meta[PasswordHash] = Meta[String].timap(PasswordHash.apply)(_.toString)
   given Meta[SessionId]    = Meta[String].timap(SessionId.apply)(_.toString)
   given Meta[UnlockToken]  = Meta[String].timap(UnlockToken.apply)(_.toString)
@@ -37,7 +39,7 @@
 
   private val lockedFilter         = fr"""locked_at IS NOT NULL"""
   private val notLockedFilter      = fr"""locked_at IS NULL"""
-  private val selectAccountColumns = fr"""SELECT uid, name, email, validated_email FROM "hub"."accounts""""
+  private val selectAccountColumns = fr"""SELECT uid, name, email, validated_email, language FROM "hub"."accounts""""
 
   override def createUserSession(session: Session): F[Int] =
     sql"""INSERT INTO "hub"."sessions" (id, uid, created_at, updated_at) VALUES (${session.id}, ${session.uid}, ${session.createdAt}, ${session.updatedAt})""".update.run
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-31 07:57:26.142415137 +0000
@@ -20,6 +20,7 @@
 import java.nio.charset.StandardCharsets
 import java.nio.file.Files
 import java.nio.file.attribute.PosixFilePermissions
+import java.util.Locale
 
 import cats.arrow.FunctionK
 import cats.effect._
@@ -27,13 +28,13 @@
 import com.typesafe.config._
 import de.smederee.darcs._
 import de.smederee.email.SimpleJavaMailMiddleware
-import de.smederee.html._
 import de.smederee.html.LinkTools._
+import de.smederee.html._
 import de.smederee.hub.config._
 import de.smederee.security._
 import de.smederee.ssh._
-import de.smederee.tickets.config._
 import de.smederee.tickets._
+import de.smederee.tickets.config._
 import doobie._
 import org.http4s._
 import org.http4s.ember.server._
@@ -90,6 +91,7 @@
     } yield key
 
   def run(args: List[String]): IO[ExitCode] = {
+    val _                       = Locale.setDefault(Locale.ENGLISH) // TODO: Make this configurable.
     val hubDatabaseMigrator     = new de.smederee.hub.DatabaseMigrator[IO]
     val ticketsDatabaseMigrator = new de.smederee.tickets.config.DatabaseMigrator[IO]
     for {
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/LandingPageRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/LandingPageRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/LandingPageRoutes.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/LandingPageRoutes.scala	2025-01-31 07:57:26.146415144 +0000
@@ -22,6 +22,7 @@
 import de.smederee.html.LinkTools._
 import de.smederee.hub.RequestHelpers.instances.given
 import de.smederee.hub.config._
+import de.smederee.i18n.LanguageCode
 import org.http4s._
 import org.http4s.dsl.Http4sDsl
 import org.http4s.implicits._
@@ -46,8 +47,9 @@
 
   private val contact: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ GET -> Root / "contact" as user =>
     for {
-      csrf <- Sync[F].delay(ar.req.getCsrfToken)
-      resp <- Ok(views.html.contact(baseUri)(csrf, "Smederee - Contact".some, user.some))
+      csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+      language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+      resp     <- Ok(views.html.contact(baseUri, lang = language)(csrf, "Smederee - Contact".some, user.some))
     } yield resp
   }
 
@@ -60,8 +62,9 @@
 
   private val imprint: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ GET -> Root / "imprint" as user =>
     for {
-      csrf <- Sync[F].delay(ar.req.getCsrfToken)
-      resp <- Ok(views.html.imprint(baseUri)(csrf, "Smederee - Imprint / Impressum".some, user.some))
+      csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+      language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+      resp <- Ok(views.html.imprint(baseUri, lang = language)(csrf, "Smederee - Imprint / Impressum".some, user.some))
     } yield resp
   }
 
@@ -74,8 +77,11 @@
 
   private val mainSite: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ GET -> Root as user =>
     for {
-      csrf <- Sync[F].delay(ar.req.getCsrfToken)
-      resp <- Ok(views.html.index(baseUri)()(signupUri, csrf, "Welcome to the Smederee!".some, user.some))
+      csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+      language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+      resp <- Ok(
+        views.html.index(baseUri, lang = language)()(signupUri, csrf, "Welcome to the Smederee!".some, user.some)
+      )
     } yield resp
   }
 
@@ -89,8 +95,11 @@
   private val privacyPolicy: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ GET -> Root / "privacy-policy" as user =>
       for {
-        csrf <- Sync[F].delay(ar.req.getCsrfToken)
-        resp <- Ok(views.html.privacyPolicy(baseUri)(csrf, "Smederee - Privacy Policy".some, user.some))
+        csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+        language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+        resp <- Ok(
+          views.html.privacyPolicy(baseUri, lang = language)(csrf, "Smederee - Privacy Policy".some, user.some)
+        )
       } yield resp
   }
 
@@ -104,8 +113,12 @@
   private val publicAlpha: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ GET -> Root / "public-alpha" as user =>
       for {
-        csrf <- Sync[F].delay(ar.req.getCsrfToken)
-        resp <- Ok(views.html.publicAlpha(baseUri)(csrf, "Smederee - Details about our public alpha.".some, user.some))
+        csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+        language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+        resp <- Ok(
+          views.html
+            .publicAlpha(baseUri, lang = language)(csrf, "Smederee - Details about our public alpha.".some, user.some)
+        )
       } yield resp
   }
 
@@ -118,8 +131,9 @@
 
   private val termsOfUse: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ GET -> Root / "terms-of-use" as user =>
     for {
-      csrf <- Sync[F].delay(ar.req.getCsrfToken)
-      resp <- Ok(views.html.termsOfUse(baseUri)(csrf, "Smederee - Terms of Use".some, user.some))
+      csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+      language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+      resp     <- Ok(views.html.termsOfUse(baseUri, lang = language)(csrf, "Smederee - Terms of Use".some, user.some))
     } yield resp
   }
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala	2025-01-31 07:57:26.146415144 +0000
@@ -112,7 +112,8 @@
                       uid = uid,
                       name = signupForm.name,
                       email = signupForm.email,
-                      validatedEmail = false
+                      validatedEmail = false,
+                      language = None
                     )
                   )
                   hash     <- Sync[F].delay(signupForm.password.encode)
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-31 07:57:26.146415144 +0000
@@ -30,6 +30,7 @@
 import de.smederee.hub.config._
 import de.smederee.hub.forms.types.FormErrors
 import de.smederee.html.LinkTools._
+import de.smederee.i18n.LanguageCode
 import de.smederee.security.{ CsrfToken, Username }
 import org.fusesource.jansi.utils.UtilsAnsiHtml
 import org.http4s._
@@ -233,12 +234,15 @@
     */
   private def doShowAllRepositories(csrf: Option[CsrfToken])(user: Option[Account]): F[Response[F]] =
     for {
+      language <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en")))
       repos <- vcsMetadataRepo
         .listAllRepositories(user)(VcsMetadataRepositoriesOrdering.NameAscending)
         .compile
         .toList
       actionBaseUri <- Sync[F].delay(linkConfig.createFullUri(uri"projects"))
-      resp <- Ok(views.html.showAllRepositories()(actionBaseUri, csrf, s"Smederee - Projects".some, user)(repos))
+      resp <- Ok(
+        views.html.showAllRepositories(lang = language)(actionBaseUri, csrf, s"Smederee - Projects".some, user)(repos)
+      )
     } yield resp
 
   /** Logic for rendering a list of repositories of the given owner for a specific user account. This function takes
@@ -257,15 +261,16 @@
       csrf: Option[CsrfToken]
   )(repositoriesOwnerName: Username)(user: Option[Account]): F[Response[F]] =
     for {
-      owner <- vcsMetadataRepo.findVcsRepositoryOwner(repositoriesOwnerName)
-      repos <- owner.traverse(owner => vcsMetadataRepo.listRepositories(user)(owner).compile.toList)
+      owner    <- vcsMetadataRepo.findVcsRepositoryOwner(repositoriesOwnerName)
+      language <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en")))
+      repos    <- owner.traverse(owner => vcsMetadataRepo.listRepositories(user)(owner).compile.toList)
       actionBaseUri <- Sync[F].delay(
         linkConfig.createFullUri(Uri(path = Uri.Path.unsafeFromString(s"~$repositoriesOwnerName")))
       )
       resp <- owner match {
         case None => // TODO: Better error message...
           NotFound(
-            views.html.showRepositories()(
+            views.html.showRepositories(lang = language)(
               actionBaseUri,
               csrf,
               s"Smederee/~$repositoriesOwnerName".some,
@@ -274,7 +279,7 @@
           )
         case Some(_) =>
           Ok(
-            views.html.showRepositories()(
+            views.html.showRepositories(lang = language)(
               actionBaseUri,
               csrf,
               s"Smederee/~$repositoriesOwnerName".some,
@@ -301,6 +306,7 @@
       csrf: Option[CsrfToken]
   )(user: Option[Account])(repositoryOwnerName: Username, repositoryName: VcsRepositoryName): F[Response[F]] =
     for {
+      language  <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en")))
       repoAndId <- loadRepo(user)(repositoryOwnerName, repositoryName)
       repo = repoAndId.map(_._1)
       actionBaseUri <- Sync[F].delay(
@@ -359,7 +365,7 @@
         case None => NotFound("Repository not found!")
         case Some(repo) =>
           Ok(
-            views.html.showRepositoryOverview(baseUri)(
+            views.html.showRepositoryOverview(baseUri, lang = language)(
               actionBaseUri,
               csrf,
               s"Smederee/~$repositoryOwnerName/$repositoryName".some,
@@ -396,6 +402,7 @@
       user: Option[Account]
   )(repositoryOwnerName: Username, repositoryName: VcsRepositoryName)(filePath: Uri.Path): F[Response[F]] =
     for {
+      language  <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en")))
       repoAndId <- loadRepo(user)(repositoryOwnerName, repositoryName)
       repo = repoAndId.map(_._1)
       requestedFilePath <- repo
@@ -474,7 +481,7 @@
               NotFound("File not found!")
             else
               Ok(
-                views.html.showRepositoryFiles(baseUri)(
+                views.html.showRepositoryFiles(baseUri, lang = language)(
                   actionBaseUri,
                   csrf,
                   Option(goBackUri),
@@ -506,6 +513,7 @@
       fromEntry: Option[Int]
   ): F[Response[F]] =
     for {
+      language  <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en")))
       repoAndId <- loadRepo(user)(repositoryOwnerName, repositoryName)
       repo = repoAndId.map(_._1)
       directory <- Sync[F].delay(
@@ -565,7 +573,7 @@
         case None => NotFound()
         case Some(repo) =>
           Ok(
-            views.html.showRepositoryHistory(baseUri)(
+            views.html.showRepositoryHistory(baseUri, lang = language)(
               actionBaseUri,
               csrf,
               Option(goBackUri),
@@ -595,6 +603,7 @@
       user: Option[Account]
   )(repositoryOwnerName: Username, repositoryName: VcsRepositoryName)(hash: DarcsHash): F[Response[F]] =
     for {
+      language  <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en")))
       repoAndId <- loadRepo(user)(repositoryOwnerName, repositoryName)
       repo = repoAndId.map(_._1)
       directory <- Sync[F].delay(
@@ -628,11 +637,12 @@
         case None => NotFound()
         case Some(repo) =>
           Ok(
-            views.html.showRepositoryPatch(baseUri)(actionBaseUri, csrf, patch.map(_.name.toString), user)(
-              patch,
-              htmlPatchDetails,
-              repo
-            )
+            views.html
+              .showRepositoryPatch(baseUri, lang = language)(actionBaseUri, csrf, patch.map(_.name.toString), user)(
+                patch,
+                htmlPatchDetails,
+                repo
+              )
           )
       }
     } yield resp
@@ -840,7 +850,8 @@
     case ar @ POST -> Root / "repo" / "create" as user =>
       ar.req.decodeStrict[F, UrlForm] { urlForm =>
         for {
-          csrf <- Sync[F].delay(ar.req.getCsrfToken)
+          csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+          language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
           _ <- Sync[F].raiseUnless(user.validatedEmail)(
             new Error(
               "An unvalidated account is not allowed to create a repository!"
@@ -860,7 +871,12 @@
             case Validated.Invalid(es) =>
               BadRequest(
                 views.html
-                  .createRepository()(createRepoPath, csrf, "Smederee - Create a new repository".some, user)(
+                  .createRepository(lang = language)(
+                    createRepoPath,
+                    csrf,
+                    "Smederee - Create a new repository".some,
+                    user
+                  )(
                     formData,
                     FormErrors.fromNec(es)
                   )
@@ -969,7 +985,8 @@
         ) / "edit" as user =>
       ar.req.decodeStrict[F, UrlForm] { urlForm =>
         for {
-          csrf <- Sync[F].delay(ar.req.getCsrfToken)
+          csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+          language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
           _ <- Sync[F].raiseUnless(user.validatedEmail)(
             new Error(
               "An unvalidated account is not allowed to edit a repository!"
@@ -1019,7 +1036,7 @@
                 resp <- form match {
                   case Validated.Invalid(errors) =>
                     BadRequest(
-                      views.html.editRepository()(
+                      views.html.editRepository(lang = language)(
                         editAction,
                         csrf,
                         repoUri,
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala	2025-01-31 07:57:26.146415144 +0000
@@ -25,6 +25,7 @@
 import de.smederee.html._
 import de.smederee.hub.RequestHelpers.instances.given
 import de.smederee.hub.Account
+import de.smederee.i18n.LanguageCode
 import de.smederee.security.{ CsrfToken, Username }
 import de.smederee.tickets.config._
 import de.smederee.tickets.forms.types._
@@ -74,6 +75,7 @@
   )(user: Option[Account])(projectOwnerName: Username)(projectName: ProjectName): F[Response[F]] =
     for {
       _            <- Sync[F].delay(log.debug(s"doShowLabels: $csrf, $user, $projectOwnerName, $projectName"))
+      language     <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en")))
       projectAndId <- loadProject(user)(projectOwnerName, projectName)
       resp <- projectAndId match {
         case Some((repo, repoId)) =>
@@ -89,7 +91,7 @@
               )
             )
             resp <- Ok(
-              views.html.editLabels()(
+              views.html.editLabels(lang = language)(
                 projectBaseUri.addSegment("labels"),
                 csrf,
                 labels,
@@ -148,6 +150,7 @@
       ar.req.decodeStrict[F, UrlForm] { urlForm =>
         for {
           csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+          language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
           projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
           resp <- projectAndId match {
             case Some(repo, repoId) =>
@@ -176,7 +179,7 @@
                 resp <- form match {
                   case Validated.Invalid(errors) =>
                     BadRequest(
-                      views.html.editLabels()(
+                      views.html.editLabels(lang = language)(
                         projectBaseUri.addSegment("labels"),
                         csrf,
                         labels.getOrElse(List.empty),
@@ -197,7 +200,7 @@
                           )
                         case Some(_) =>
                           BadRequest(
-                            views.html.editLabels()(
+                            views.html.editLabels(lang = language)(
                               projectBaseUri.addSegment("labels"),
                               csrf,
                               labels.getOrElse(List.empty),
@@ -293,6 +296,7 @@
       ar.req.decodeStrict[F, UrlForm] { urlForm =>
         for {
           csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+          language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
           projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
           label <- projectAndId match {
             case Some((_, repoId)) => labelRepo.findLabel(repoId)(labelName)
@@ -337,7 +341,7 @@
                 resp <- form match {
                   case Validated.Invalid(errors) =>
                     BadRequest(
-                      views.html.editLabel()(
+                      views.html.editLabel(lang = language)(
                         actionUri,
                         csrf,
                         label,
@@ -362,7 +366,7 @@
                           )
                         case Some(_) =>
                           BadRequest(
-                            views.html.editLabel()(
+                            views.html.editLabel(lang = language)(
                               actionUri,
                               csrf,
                               label,
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2025-01-31 07:57:26.146415144 +0000
@@ -25,6 +25,7 @@
 import de.smederee.html._
 import de.smederee.hub.Account
 import de.smederee.hub.RequestHelpers.instances.given
+import de.smederee.i18n.LanguageCode
 import de.smederee.security.{ CsrfToken, Username }
 import de.smederee.tickets.config._
 import de.smederee.tickets.forms.types._
@@ -71,6 +72,7 @@
       csrf: Option[CsrfToken]
   )(user: Option[Account])(repositoryOwnerName: Username)(repositoryName: ProjectName): F[Response[F]] =
     for {
+      language  <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en")))
       repoAndId <- loadRepo(user)(repositoryOwnerName, repositoryName)
       resp <- repoAndId match {
         case Some((repo, repoId)) =>
@@ -86,7 +88,7 @@
               )
             )
             resp <- Ok(
-              views.html.editMilestones()(
+              views.html.editMilestones(lang = language)(
                 repositoryBaseUri.addSegment("milestones"),
                 csrf,
                 milestones,
@@ -145,6 +147,7 @@
       ar.req.decodeStrict[F, UrlForm] { urlForm =>
         for {
           csrf      <- Sync[F].delay(ar.req.getCsrfToken)
+          language  <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
           repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
           resp <- repoAndId match {
             case Some(repo, repoId) =>
@@ -173,7 +176,7 @@
                 resp <- form match {
                   case Validated.Invalid(errors) =>
                     BadRequest(
-                      views.html.editMilestones()(
+                      views.html.editMilestones(lang = language)(
                         repositoryBaseUri.addSegment("milestones"),
                         csrf,
                         milestones.getOrElse(List.empty),
@@ -195,7 +198,7 @@
                           )
                         case Some(_) =>
                           BadRequest(
-                            views.html.editMilestones()(
+                            views.html.editMilestones(lang = language)(
                               repositoryBaseUri.addSegment("milestones"),
                               csrf,
                               milestones.getOrElse(List.empty),
@@ -293,6 +296,7 @@
       ar.req.decodeStrict[F, UrlForm] { urlForm =>
         for {
           csrf      <- Sync[F].delay(ar.req.getCsrfToken)
+          language  <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
           repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
           milestone <- repoAndId match {
             case Some((_, repoId)) => milestoneRepo.findMilestone(repoId)(milestoneTitle)
@@ -339,7 +343,7 @@
                 resp <- form match {
                   case Validated.Invalid(errors) =>
                     BadRequest(
-                      views.html.editMilestone()(
+                      views.html.editMilestone(lang = language)(
                         actionUri,
                         csrf,
                         milestone,
@@ -368,7 +372,7 @@
                           )
                         case Some(_) =>
                           BadRequest(
-                            views.html.editMilestone()(
+                            views.html.editMilestone(lang = language)(
                               actionUri,
                               csrf,
                               milestone,
@@ -401,6 +405,7 @@
         ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) / "edit" as user =>
       for {
         csrf      <- Sync[F].delay(ar.req.getCsrfToken)
+        language  <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
         repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
         milestone <- repoAndId match {
           case Some((_, repoId)) => milestoneRepo.findMilestone(repoId)(milestoneTitle)
@@ -421,7 +426,7 @@
               actionUri <- Sync[F].delay(repositoryBaseUri.addSegment("milestone").addSegment(milestone.title.toString))
               formData  <- Sync[F].delay(MilestoneForm.fromMilestone(milestone))
               resp <- Ok(
-                views.html.editMilestone()(
+                views.html.editMilestone(lang = language)(
                   actionUri,
                   csrf,
                   milestone,
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html	2025-01-31 07:57:26.146415144 +0000
@@ -1,26 +1,27 @@
+@import java.util.Locale
 @import de.smederee.hub._
 @import de.smederee.hub.views.html._
 
-@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(actionBaseUri: Uri, deleteAction: Uri, validateAction: Uri)
+@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(actionBaseUri: Uri, deleteAction: Uri, languageAction: Uri, validateAction: Uri)
 @main(baseUri, lang)()(csrf, title, user.some) {
 @defining(lang.toLocale) { implicit locale =>
 <div class="content">
-    <div class="pure-g">
-      <div class="pure-u-1-1 pure-u-md-1-1">
-        <div class="l-box-left-right">
-          <h2>~@user.name / Settings</h2>
-          <nav class="pure-menu pure-menu-horizontal">
-            <ul class="pure-menu-list">
-              <li class="pure-menu-item pure-menu-active"><a class="pure-menu-link" href="@actionBaseUri">@Messages("user.settings.account.title")</a></li>
-              <li class="pure-menu-item"><a class="pure-menu-link" href="@actionBaseUri.addSegment("ssh")">@Messages("user.settings.ssh.title")</a></li>
-            </ul>
-          </nav>
-          <div class="account-settings-description">
-            @Messages("user.settings.account.description")
-          </div>
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box-left-right">
+        <h2>~@user.name / Settings</h2>
+        <nav class="pure-menu pure-menu-horizontal">
+          <ul class="pure-menu-list">
+            <li class="pure-menu-item pure-menu-active"><a class="pure-menu-link" href="@actionBaseUri">@Messages("user.settings.account.title")</a></li>
+            <li class="pure-menu-item"><a class="pure-menu-link" href="@actionBaseUri.addSegment("ssh")">@Messages("user.settings.ssh.title")</a></li>
+          </ul>
+        </nav>
+        <div class="account-settings-description">
+          @Messages("user.settings.account.description")
         </div>
       </div>
     </div>
+  </div>
   <div class="pure-g">
     @if(user.validatedEmail) {
     } else {
@@ -61,6 +62,29 @@
             </fieldset>
           </form>
         </div>
+      </div>
+    </div>
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <div class="set-language-form">
+          <h4>@Messages("user.settings.language.title")</h4>
+          <form action="@languageAction" class="pure-form" method="POST" accept-charset="UTF-8">
+            <fieldset>
+              <div class="pure-control-group">
+                <label for="language">@Messages("form.account.language")</label>
+                <select id="language" name="language">
+                  <option value="">Use default site language.</option>
+                  @for(locale <- List(Locale.GERMAN, Locale.ENGLISH)) {
+                    <option value="@{locale.getLanguage}" @if(user.language.exists(_.toString === locale.getLanguage)){selected="selected"}else{}>@{locale.getDisplayName}</option>
+                  }
+                </select>
+                <span class="pure-form-message" id="language.help">@Messages("form.account.language.help")</span>
+              </div>
+              @csrfToken(csrf)
+              <button type="submit" class="pure-button">@Messages("form.account.language.button.submit")</button>
+            </fieldset>
+          </form>
+        </div>
       </div>
     </div>
   </div>
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-01-31 07:57:26.146415144 +0000
@@ -23,6 +23,7 @@
 
 import cats.syntax.all._
 import de.smederee.email.EmailAddress
+import de.smederee.i18n.LanguageCode
 import de.smederee.security._
 
 import org.scalacheck._
@@ -34,6 +35,11 @@
   val MinimumYear: Int = -4713  // Lowest year supported by PostgreSQL
   val MaximumYear: Int = 294276 // Highest year supported by PostgreSQL
 
+  val genLocale: Gen[Locale] = Gen.oneOf(Locale.getAvailableLocales.toList)
+  given Arbitrary[Locale]    = Arbitrary(genLocale)
+
+  val genLanguageCode: Gen[LanguageCode] = genLocale.map(_.getISO3Language).filter(_.nonEmpty).map(LanguageCode.apply)
+
   val genFiniteDuration: Gen[FiniteDuration] =
     Gen.choose(0, Int.MaxValue).map(seconds => FiniteDuration(seconds, SECONDS))
 
@@ -108,7 +114,8 @@
     email          <- genValidEmail
     name           <- genValidUsername
     validatedEmail <- Gen.oneOf(List(false, true))
-  } yield Account(uid = id, name = name, email = email, validatedEmail = validatedEmail)
+    language       <- Gen.option(genLanguageCode)
+  } yield Account(uid = id, name = name, email = email, validatedEmail = validatedEmail, language = language)
 
   given Arbitrary[Account] = Arbitrary(genValidAccount)
 
diff -rN -u old-smederee/modules/i18n/src/main/scala/de/smederee/i18n/LanguageCode.scala new-smederee/modules/i18n/src/main/scala/de/smederee/i18n/LanguageCode.scala
--- old-smederee/modules/i18n/src/main/scala/de/smederee/i18n/LanguageCode.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/i18n/src/main/scala/de/smederee/i18n/LanguageCode.scala	2025-01-31 07:57:26.146415144 +0000
@@ -17,10 +17,14 @@
 
 package de.smederee.i18n
 
+import cats._
+
 import scala.util.matching.Regex
 
 opaque type LanguageCode = String
 object LanguageCode {
+  given Eq[LanguageCode] = Eq.fromUniversalEquals
+
   val FormatIso639: Regex = "^[a-z]{2,3}$".r
 
   /** Create an instance of LanguageCode from the given String type.
diff -rN -u old-smederee/modules/i18n/src/main/scala/de/smederee/i18n/Messages.scala new-smederee/modules/i18n/src/main/scala/de/smederee/i18n/Messages.scala
--- old-smederee/modules/i18n/src/main/scala/de/smederee/i18n/Messages.scala	2025-01-31 07:57:26.138415129 +0000
+++ new-smederee/modules/i18n/src/main/scala/de/smederee/i18n/Messages.scala	2025-01-31 07:57:26.146415144 +0000
@@ -91,9 +91,10 @@
   @throws[java.util.MissingResourceException]("if the message key cannot be found in the resource bundle")
   @throws[java.lang.ClassCastException]("if the object found for the given key is not a string")
   def getProperty(locale: Locale)(key: String): String = {
-    val usedLocale = Option(getClass.getResource(s"messages_${locale.getLanguage.toString}.properties"))
-      .map(_ => locale)
-      .getOrElse(DefaultLocale)
+    val filename = s"messages_${locale.getLanguage.toString}.properties"
+    log.debug("Checking for resource bundle: {}", filename)
+    val usedLocale = Option(getClass.getClassLoader.getResource(filename)).map(_ => locale).getOrElse(DefaultLocale)
+    log.debug("Using resource bundle for locale: {}", usedLocale)
     val bundle = ResourceBundle.getBundle(Filename, usedLocale)
     bundle.getString(key)
   }
diff -rN -u old-smederee/modules/i18n/src/test/resources/messages_de.properties new-smederee/modules/i18n/src/test/resources/messages_de.properties
--- old-smederee/modules/i18n/src/test/resources/messages_de.properties	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/i18n/src/test/resources/messages_de.properties	2025-01-31 07:57:26.146415144 +0000
@@ -0,0 +1,2 @@
+# *** FILE ENCODING MUST BE UTF-8! ***
+test.title = Das ist eine deutsche Übersetzung!
diff -rN -u old-smederee/modules/i18n/src/test/resources/messages_en.properties new-smederee/modules/i18n/src/test/resources/messages_en.properties
--- old-smederee/modules/i18n/src/test/resources/messages_en.properties	2025-01-31 07:57:26.142415137 +0000
+++ new-smederee/modules/i18n/src/test/resources/messages_en.properties	1970-01-01 00:00:00.000000000 +0000
@@ -1,2 +0,0 @@
-# *** FILE ENCODING MUST BE UTF-8! ***
-test.title = I am a weird title contähnüng some special characters 文字 so it should be correct!
diff -rN -u old-smederee/modules/i18n/src/test/resources/messages.properties new-smederee/modules/i18n/src/test/resources/messages.properties
--- old-smederee/modules/i18n/src/test/resources/messages.properties	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/i18n/src/test/resources/messages.properties	2025-01-31 07:57:26.146415144 +0000
@@ -0,0 +1,3 @@
+# *** FILE ENCODING MUST BE UTF-8! ***
+test.title = I am a weird title contähnüng some special characters 文字 so it should be correct!
+test.main.only = This should only exist in the main properties file.
diff -rN -u old-smederee/modules/i18n/src/test/scala/de/smederee/i18n/MessagesTest.scala new-smederee/modules/i18n/src/test/scala/de/smederee/i18n/MessagesTest.scala
--- old-smederee/modules/i18n/src/test/scala/de/smederee/i18n/MessagesTest.scala	2025-01-31 07:57:26.142415137 +0000
+++ new-smederee/modules/i18n/src/test/scala/de/smederee/i18n/MessagesTest.scala	2025-01-31 07:57:26.146415144 +0000
@@ -30,13 +30,21 @@
   property("Messages must fall back to default locale if expected one is missing") {
     forAll { (locale: Locale) =>
       given Locale = locale
-      val message  = "test.title"
-      val expected = "I am a weird title contähnüng some special characters 文字 so it should be correct!"
+      val message  = "test.main.only"
+      val expected = "This should only exist in the main properties file."
       val obtained = Messages(message)
       assertEquals(obtained, expected)
     }
   }
 
+  test("Messages must translate correctly") {
+    given Locale = Locale.GERMAN
+    val message  = "test.title"
+    val expected = "Das ist eine deutsche Übersetzung!"
+    val obtained = Messages(message)
+    assertEquals(obtained, expected)
+  }
+
   property("Messages must return the given key if it is not defined in the resource bundle") {
     forAll { (message: String) =>
       given Locale    = Messages.DefaultLocale