~jan0sch/smederee

Showing details for patch 252c14a83f46a13760979e5af3a79fb46772670a.
2023-03-19 (Sun), 1:11 PM - Jens Grassel - 252c14a83f46a13760979e5af3a79fb46772670a

Refactoring: Heavy Refactoring to move tickets into separate module.

This patch is _huge_ and breaks out the already implemented tickets features
(labels and milestones) into a separate module. Very likely the ticketing
will move into a service of its own so this will ease things further down
the road. But yes, it has complicated things now. ;-)

BREAKING CHANGES
----------------

- module configuration is now wrapped into a top most "module" value

So instead of having

```
database
service
```

in the configuration for the hub module, now we have

```
hub {
  database
  service
}
```

General changes
---------------

- add more flexible ignore rule for module specific local configurations
- adjust options for the JVM when running sbt
- update Scala to 3.3.0-RC to be able to use code health checks
- enable code health checks (unused warnings etc.)
- update flyway to 9.15.2
- update circe to 0.14.5
- update os-lib to 0.9.1

Database layer and migrations
-----------------------------

- use schemas per module to avoid clashes within the same database
- move migration files into specific folders to avoid conflicts with flyway

Email module
------------

- add EmailAddress type (moved from hub Email type)

Hub module
----------

- move some types and classes to other modules because of usage also for
  tickets
- implement views (twirl templates) for tickets
- limit length of patch comment on repository overview page

i18n module
-----------

- add LanguageCode types (moved from hub module)

Security module
---------------

- add CsrfToken, Password, PasswordHash, UserId and Username (moved from
  hub module)

Tickets module
--------------

- add database migrations for base tables
- add base classes (moved from hub) and configuration
Summary of changes
69 files added
  • modules/hub/src/main/resources/db/migration/hub/V1__base_tables.sql
  • modules/hub/src/main/resources/db/migration/hub/V2__repository_tables.sql
  • modules/hub/src/main/resources/db/migration/hub/V3__fork_tables.sql
  • modules/hub/src/main/scala/de/smederee/hub/config/ConfigurationPath.scala
  • modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala
  • modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala
  • modules/hub/src/main/scala/de/smederee/tickets/UsernamePathParameter.scala
  • modules/hub/src/main/twirl/de/smederee/tickets/views/csrfToken.scala.html
  • modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html
  • modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html
  • modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html
  • modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html
  • modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDate.scala.html
  • modules/hub/src/main/twirl/de/smederee/tickets/views/forms/renderFormErrors.scala.html
  • modules/hub/src/main/twirl/de/smederee/tickets/views/icon.scala.html
  • modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html
  • modules/i18n/src/main/scala/de/smederee/i18n/LanguageCode.scala
  • modules/i18n/src/test/scala/de/smederee/i18n/LanguageCodeTest.scala
  • modules/security/src/main/scala/de/smederee/security/CsrfToken.scala
  • modules/security/src/main/scala/de/smederee/security/Password.scala
  • modules/security/src/main/scala/de/smederee/security/PasswordHash.scala
  • modules/security/src/main/scala/de/smederee/security/UserId.scala
  • modules/security/src/main/scala/de/smederee/security/Username.scala
  • modules/security/src/test/scala/de/smederee/security/PasswordTest.scala
  • modules/security/src/test/scala/de/smederee/security/UserIdTest.scala
  • modules/tickets/src/it/resources/application.conf
  • modules/tickets/src/it/resources/logback-test.xml
  • modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala
  • modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala
  • modules/tickets/src/it/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala
  • modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql
  • modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql
  • modules/tickets/src/main/resources/logback.xml
  • modules/tickets/src/main/resources/reference.conf
  • modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/Label.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/Project.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala
  • modules/tickets/src/test/resources/logback-test.xml
  • modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala
82 files modified with 578 lines added and 700 lines removed
  • .ignore with 1 added and 1 removed lines
  • .jvmopts with 7 added and 4 removed lines
  • build.sbt with 67 added and 16 removed lines
  • modules/darcs/src/main/scala/de/smederee/darcs/DarcsCommands.scala with 0 added and 1 removed lines
  • modules/email/src/main/scala/de/smederee/email/EmailMiddleware.scala with 70 added and 0 removed lines
  • modules/email/src/test/scala/de/smederee/email/EmailMiddlewareTest.scala with 0 added and 6 removed lines
  • modules/email/src/test/scala/de/smederee/email/SimpleJavaMailMiddlewareHelpersTest.scala with 0 added and 1 removed lines
  • modules/html-utils/src/test/scala/de/smederee/html/LinkToolsTest.scala with 1 added and 2 removed lines
  • modules/hub/src/it/resources/application.conf with 11 added and 7 removed lines
  • modules/hub/src/it/resources/logback-test.xml with 4 added and 0 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala with 7 added and 4 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala with 1 added and 0 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala with 1 added and 0 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala with 1 added and 0 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala with 5 added and 3 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/Generators.scala with 15 added and 7 removed lines
  • modules/hub/src/main/resources/reference.conf with 158 added and 156 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/Account.scala with 19 added and 211 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala with 1 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala with 1 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AuthenticationMiddleware.scala with 0 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala with 4 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala with 2 added and 5 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DatabaseMigrator.scala with 1 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala with 3 added and 5 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/DoobieSignupRepository.scala with 5 added and 3 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala with 1 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 45 added and 28 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/LoginForm.scala with 1 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala with 1 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/RequestHelpers.scala with 2 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/Session.scala with 1 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/SignupForm.scala with 5 added and 3 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala with 4 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala with 2 added and 6 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala with 1 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala with 4 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 15 added and 6 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala with 8 added and 124 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/types.scala with 1 added and 54 removed lines
  • modules/hub/src/main/scala/de/smederee/ssh/DarcsSshCommand.scala with 2 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala with 1 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala with 1 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticationRepository.scala with 1 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala with 1 added and 4 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html with 3 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html with 4 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/contact.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/createRepository.scala.html with 3 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/csrfToken.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html with 3 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt with 1 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html with 3 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/forms/renderFormErrors.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/imprint.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/index.scala.html with 1 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/login.scala.html with 3 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/main.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/privacyPolicy.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/publicAlpha.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showAllRepositories.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html with 3 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html with 1 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/signup.scala.html with 3 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/termsOfUse.scala.html with 2 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/welcome.scala.html with 2 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala with 4 added and 12 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/Generators.scala with 15 added and 7 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala with 4 added and 2 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/config/ServiceConfigTest.scala with 5 added and 5 removed lines
  • modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala with 1 added and 0 removed lines
  • modules/security/src/main/scala/de/smederee/security/PrivateKey.scala with 0 added and 3 removed lines
  • project/plugins.sbt with 1 added and 1 removed lines
  • twirl/src/main/scala/org/http4s/twirl/TwirlInstances.scala with 0 added and 1 removed lines
5 files removed
  • modules/hub/src/main/resources/db/migration/V1__base_tables.sql
  • modules/hub/src/main/resources/db/migration/V2__repository_tables.sql
  • modules/hub/src/main/resources/db/migration/V3__fork_tables.sql
  • modules/hub/src/test/scala/de/smederee/hub/PasswordTest.scala
  • modules/hub/src/test/scala/de/smederee/hub/UserIdTest.scala
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt
--- old-smederee/build.sbt	2025-01-31 10:46:28.154967279 +0000
+++ new-smederee/build.sbt	2025-01-31 10:46:28.170967306 +0000
@@ -14,7 +14,7 @@
 
 inThisBuild(
   Seq(
-    scalaVersion := "3.2.2",
+    scalaVersion := "3.3.0-RC3",
     organization := "de.smederee",
     organizationName := "Contributors as noted in the AUTHORS.md file",
     scalacOptions ++= Seq(
@@ -25,7 +25,14 @@
         "-language:higherKinds",
         "-language:implicitConversions",
         "-unchecked",
-        "-Xfatal-warnings",
+        "-Wunused:imports", // Warn on unused imports including given and wildcard imports.
+        "-Wunused:linted",
+        "-Wunused:locals",
+        //"-Wunused:params",
+        "-Wunused:privates",
+        //"-Wunused:unsafe-warn-patvars",
+        //"-Wvalue-discard", // TODO: Evaluate and apply if possible.
+        //"-Xfatal-warnings", // FIXME: Make this work despite of Twirl!
         "-Ykind-projector",
     ),
     resolvers += "jitpack" at "https://jitpack.io", // for JANSI fork
@@ -50,7 +57,7 @@
       publish := {},
       publishLocal := {}
     )
-    .aggregate(darcs, email, htmlUtils, hub, i18n, security, twirl)
+    .aggregate(darcs, email, htmlUtils, hub, i18n, security, tickets, twirl)
 
 lazy val darcs =
   project
@@ -140,7 +147,7 @@
 lazy val hub =
   project
     .in(file("modules/hub"))
-    .dependsOn(darcs, email, htmlUtils, i18n, security, twirl)
+    .dependsOn(darcs, email, htmlUtils, i18n, security, tickets, twirl)
     .enablePlugins(
       AutomateHeaderPlugin,
       DebianPlugin,
@@ -208,15 +215,8 @@
       TwirlKeys.templateImports ++= Seq(
         "cats.syntax.all._",
         "de.smederee.html._",
-        "de.smederee.hub._",
-        "de.smederee.hub.config._",
-        "de.smederee.hub.forms.types._",
-        "de.smederee.hub.forms._",
-        "de.smederee.hub.views.html.csrfToken",
-        "de.smederee.hub.views.html.forms.renderFormErrors",
-        "de.smederee.hub.views.html.icon",
-        "de.smederee.hub.views.html.main",
-        "de.smederee.i18n.Messages",
+        "de.smederee.i18n._",
+        "de.smederee.security.CsrfToken",
         "org.http4s.Uri"
       )
     )
@@ -314,6 +314,57 @@
       ),
     )
 
+lazy val tickets =
+  project
+    .in(file("modules/tickets"))
+    .dependsOn(email, htmlUtils, security)
+    .enablePlugins(AutomateHeaderPlugin)
+    .configs(IntegrationTest)
+    .settings(commonSettings)
+    .settings(
+      name := "tickets",
+      version := "0.5.0-SNAPSHOT",
+      Defaults.itSettings,
+      headerSettings(IntegrationTest),
+      inConfig(IntegrationTest)(scalafmtSettings),
+      IntegrationTest / console / scalacOptions --= Seq("-Xfatal-warnings"),
+      IntegrationTest / parallelExecution := false,
+      libraryDependencies ++= Seq(
+        library.catsCore,
+        library.circeCore,
+        library.circeGeneric,
+        library.circeParser,
+        library.commonMark,
+        library.commonMarkExtHeadingAnchor,
+        library.commonMarkExtTables,
+        library.commonMarkExtTaskListItems,
+        library.doobieCore,
+        library.doobieHikari,
+        library.doobiePostgres,
+        library.flywayCore,
+        library.http4sCirce,
+        library.http4sDsl,
+        library.http4sEmberClient,
+        library.http4sEmberServer,
+        //library.http4sTwirl,
+        library.jclOverSlf4j, // Bridge Java Commons Logging to SLF4J.
+        library.logback,
+        library.postgresql,
+        library.pureConfig,
+        library.springSecurityCrypto,
+        library.munit             % IntegrationTest,
+        library.munitCatsEffect   % IntegrationTest,
+        library.munitDiscipline   % IntegrationTest,
+        library.munitScalaCheck   % IntegrationTest,
+        library.scalaCheck        % IntegrationTest,
+        library.munit             % Test,
+        library.munitCatsEffect   % Test,
+        library.munitDiscipline   % Test,
+        library.munitScalaCheck   % Test,
+        library.scalaCheck        % Test
+      )
+    )
+
 // FIXME This is a workaround until http4s-twirl gets published properly for Scala 3!
 lazy val twirl =
   project
@@ -342,10 +393,10 @@
       val bouncyCastle    = "1.72"
       val cats            = "2.9.0"
       val catsEffect      = "3.4.8"
-      val circe           = "0.14.4"
+      val circe           = "0.14.5"
       val commonMark      = "0.21.0"
       val doobie          = "1.0.0-RC2"
-      val flyway          = "9.15.1"
+      val flyway          = "9.15.2"
       val fs2             = "3.5.0"
       val http4s          = "1.0.0-M39"
       val ip4s            = "3.2.0"
@@ -355,7 +406,7 @@
       val munit           = "0.7.29"
       val munitCatsEffect = "1.0.7"
       val munitDiscipline = "1.0.9"
-      val osLib           = "0.9.0"
+      val osLib           = "0.9.1"
       val postgresql      = "42.6.0"
       val pureConfig      = "0.17.2"
       val scalaCheck      = "1.17.0"
diff -rN -u old-smederee/.ignore new-smederee/.ignore
--- old-smederee/.ignore	2025-01-31 10:46:28.154967279 +0000
+++ new-smederee/.ignore	2025-01-31 10:46:28.170967306 +0000
@@ -24,4 +24,4 @@
 Session.vim
 tags
 # Project speficic files for local development
-modules/hub/src/main/resources/application.conf
+modules/.*/src/main/resources/application.conf
diff -rN -u old-smederee/.jvmopts new-smederee/.jvmopts
--- old-smederee/.jvmopts	2025-01-31 10:46:28.154967279 +0000
+++ new-smederee/.jvmopts	2025-01-31 10:46:28.170967306 +0000
@@ -1,5 +1,8 @@
--server
--Xms2g
--Xmx2g
--Xss4m
+-Dfile.encoding=UTF8
+-Xms1G
+-Xmx3G
+-Xss4M
+-XX:MaxMetaspaceSize=512M
+-XX:ReservedCodeCacheSize=256M
+-XX:+TieredCompilation
 -XX:+UseG1GC
diff -rN -u old-smederee/modules/darcs/src/main/scala/de/smederee/darcs/DarcsCommands.scala new-smederee/modules/darcs/src/main/scala/de/smederee/darcs/DarcsCommands.scala
--- old-smederee/modules/darcs/src/main/scala/de/smederee/darcs/DarcsCommands.scala	2025-01-31 10:46:28.154967279 +0000
+++ new-smederee/modules/darcs/src/main/scala/de/smederee/darcs/DarcsCommands.scala	2025-01-31 10:46:28.170967306 +0000
@@ -25,7 +25,6 @@
 import cats.syntax.all._
 import org.slf4j.LoggerFactory
 
-import scala.sys.process._
 import scala.util.matching.Regex
 
 opaque type DarcsHash = String
diff -rN -u old-smederee/modules/email/src/main/scala/de/smederee/email/EmailMiddleware.scala new-smederee/modules/email/src/main/scala/de/smederee/email/EmailMiddleware.scala
--- old-smederee/modules/email/src/main/scala/de/smederee/email/EmailMiddleware.scala	2025-01-31 10:46:28.154967279 +0000
+++ new-smederee/modules/email/src/main/scala/de/smederee/email/EmailMiddleware.scala	2025-01-31 10:46:28.170967306 +0000
@@ -91,6 +91,76 @@
 
 }
 
+/** An email address must fulfil several format requirements which in detail should be looked up in the implementation.
+  */
+opaque type EmailAddress = String
+object EmailAddress {
+  given Eq[EmailAddress] = Eq.fromUniversalEquals
+
+  val validateString: Regex =
+    """^([a-zA-Z0-9.!#$%&’'*+/=?^_`{|}~-]+)@([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*)$""".r
+
+  /** Create an instance of EmailAddress from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a EmailAddress.
+    * @return
+    *   The appropriate instance of EmailAddress.
+    */
+  def apply(source: String): EmailAddress = source
+
+  /** Try to create an instance of EmailAddress from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a EmailAddress.
+    * @return
+    *   An option to the successfully converted EmailAddress.
+    */
+  def from(source: String): Option[EmailAddress] = {
+    // Must obviously not be null or empty.
+    val notEmpty = Option(source)
+    // Must have at least one '@' sign.
+    val hasAtChar = Option(source).filter(_.exists(_ === '@'))
+    // Must have at least one '.' character.
+    val hasDotChar = Option(source).filter(_.exists(_ === '.'))
+    // The maximum allowed length is 128 characters.
+    val belowMaxLength = Option(source).filter(_.length <= 128)
+    // The last part of the email address (usually the top level domain) must at least be 2 characters long.
+    val lastPartValid = Option(source) match {
+      case None => None
+      case Some(string) =>
+        val parts = string.split('.')
+        if (parts.lastOption.map(_.length >= 2).getOrElse(false))
+          Option(string)
+        else
+          None
+    }
+    // Check against a regular expression. TODO Maybe we can skip the other tests alltogether?
+    val passesRegularExpression = Option(source).filter(validateString.matches)
+    // Validate pre-conditions
+    (notEmpty, hasAtChar, hasDotChar, belowMaxLength, lastPartValid, passesRegularExpression).mapN {
+      case (email, _, _, _, _, _) => email
+    }
+  }
+
+  extension (email: EmailAddress) {
+
+    /** Convert the email into a [[de.smederee.email.FromAddress]] to be useable within the email middleware.
+      *
+      * @return
+      *   The email address to be used in a `From` header.
+      */
+    def toFromAddress: FromAddress = FromAddress(email.toString)
+
+    /** Convert the email into a [[de.smederee.email.ToAddress]] to be useable within the email middleware.
+      *
+      * @return
+      *   The email address to be used in a `To` header.
+      */
+    def toToAddress: ToAddress = ToAddress(email.toString)
+  }
+}
+
 opaque type FromAddress = String
 object FromAddress {
   given Eq[FromAddress] = Eq.fromUniversalEquals
diff -rN -u old-smederee/modules/email/src/test/scala/de/smederee/email/EmailMiddlewareTest.scala new-smederee/modules/email/src/test/scala/de/smederee/email/EmailMiddlewareTest.scala
--- old-smederee/modules/email/src/test/scala/de/smederee/email/EmailMiddlewareTest.scala	2025-01-31 10:46:28.154967279 +0000
+++ new-smederee/modules/email/src/test/scala/de/smederee/email/EmailMiddlewareTest.scala	2025-01-31 10:46:28.170967306 +0000
@@ -19,18 +19,12 @@
 
 import java.nio.charset.StandardCharsets
 
-import cats.data._
-import cats.kernel.Eq
-import cats.syntax.all._
 import de.smederee.email.Generators._
-import jakarta.mail.Message.RecipientType
 
 import munit._
 import org.scalacheck._
 import org.scalacheck.Prop._
 
-import scala.jdk.CollectionConverters._
-
 final class EmailMiddlewareTest extends ScalaCheckSuite {
   given Arbitrary[FromAddress] = Arbitrary(genValidFromAddress)
   given Arbitrary[ToAddress]   = Arbitrary(genValidToAddress)
diff -rN -u old-smederee/modules/email/src/test/scala/de/smederee/email/SimpleJavaMailMiddlewareHelpersTest.scala new-smederee/modules/email/src/test/scala/de/smederee/email/SimpleJavaMailMiddlewareHelpersTest.scala
--- old-smederee/modules/email/src/test/scala/de/smederee/email/SimpleJavaMailMiddlewareHelpersTest.scala	2025-01-31 10:46:28.154967279 +0000
+++ new-smederee/modules/email/src/test/scala/de/smederee/email/SimpleJavaMailMiddlewareHelpersTest.scala	2025-01-31 10:46:28.170967306 +0000
@@ -17,7 +17,6 @@
 
 package de.smederee.email
 
-import cats.data._
 import cats.kernel.Eq
 import cats.syntax.all._
 import de.smederee.email.Generators._
diff -rN -u old-smederee/modules/html-utils/src/test/scala/de/smederee/html/LinkToolsTest.scala new-smederee/modules/html-utils/src/test/scala/de/smederee/html/LinkToolsTest.scala
--- old-smederee/modules/html-utils/src/test/scala/de/smederee/html/LinkToolsTest.scala	2025-01-31 10:46:28.154967279 +0000
+++ new-smederee/modules/html-utils/src/test/scala/de/smederee/html/LinkToolsTest.scala	2025-01-31 10:46:28.170967306 +0000
@@ -18,12 +18,11 @@
 package de.smederee.html
 
 import com.comcast.ip4s._
+import de.smederee.html.LinkTools.createFullUri
 import org.http4s.Uri
 import org.http4s.implicits._
 
 import munit._
-import org.scalacheck._
-import de.smederee.html.LinkTools.createFullUri
 
 final class LinkToolsTest extends ScalaCheckSuite {
 
diff -rN -u old-smederee/modules/hub/src/it/resources/application.conf new-smederee/modules/hub/src/it/resources/application.conf
--- old-smederee/modules/hub/src/it/resources/application.conf	2025-01-31 10:46:28.154967279 +0000
+++ new-smederee/modules/hub/src/it/resources/application.conf	2025-01-31 10:46:28.170967306 +0000
@@ -1,8 +1,12 @@
-database {
-  url  = "jdbc:postgresql://localhost:5432/smederee_hub_it"
-  url  = ${?SMEDEREE_HUB_TEST_DB_URL}
-  user = "smederee_hub"
-  user = ${?SMEDEREE_HUB_TEST_DB_USER}
-  pass = "secret"
-  pass = ${?SMEDEREE_HUB_TEST_DB_PASS}
+hub {
+  database {
+	host = localhost
+	host = ${?SMEDEREE_DB_HOST}
+	url  = "jdbc:postgresql://"${hub.database.host}":5432/smederee_hub_it"
+	url  = ${?SMEDEREE_HUB_TEST_DB_URL}
+	user = "smederee_hub"
+	user = ${?SMEDEREE_HUB_TEST_DB_USER}
+	pass = "secret"
+	pass = ${?SMEDEREE_HUB_TEST_DB_PASS}
+  }
 }
diff -rN -u old-smederee/modules/hub/src/it/resources/logback-test.xml new-smederee/modules/hub/src/it/resources/logback-test.xml
--- old-smederee/modules/hub/src/it/resources/logback-test.xml	2025-01-31 10:46:28.154967279 +0000
+++ new-smederee/modules/hub/src/it/resources/logback-test.xml	2025-01-31 10:46:28.170967306 +0000
@@ -19,6 +19,10 @@
     <appender-ref ref="async-console"/>
   </logger>
 
+  <logger name="org.flywaydb.core" level="ERROR" additivity="false">
+	<appender-ref ref="async-console"/>
+  </logger>
+
   <root>
     <appender-ref ref="async-console"/>
   </root>
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 10:46:28.154967279 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala	2025-01-31 10:46:28.170967306 +0000
@@ -26,14 +26,14 @@
 import cats.syntax.all._
 import com.comcast.ip4s._
 import com.typesafe.config.ConfigFactory
+import de.smederee.email.EmailAddress
 import de.smederee.hub.config._
+import de.smederee.security._
 import org.flywaydb.core.Flyway
 import pureconfig._
 
 import munit._
 
-import scala.concurrent.duration._
-
 /** Base class for our integration test suites.
   *
   * It loads and provides the configuration as a resource suit fixture (loaded once for all tests within a suite) and
@@ -43,7 +43,10 @@
 abstract class BaseSpec extends CatsEffectSuite {
 
   protected final val configuration: SmedereeHubConfig =
-    ConfigSource.fromConfig(ConfigFactory.load(getClass.getClassLoader)).loadOrThrow[SmedereeHubConfig]
+    ConfigSource
+      .fromConfig(ConfigFactory.load(getClass.getClassLoader))
+      .at(SmedereeHubConfig.location)
+      .loadOrThrow[SmedereeHubConfig]
 
   protected final val flyway: Flyway =
     DatabaseMigrator
@@ -252,7 +255,7 @@
               Account(
                 uid = uid,
                 name = Username(result.getString("name")),
-                email = Email(result.getString("email")),
+                email = EmailAddress(result.getString("email")),
                 validatedEmail = result.getBoolean("validated_email")
               )
             )
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 10:46:28.154967279 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala	2025-01-31 10:46:28.170967306 +0000
@@ -24,6 +24,7 @@
 import cats.syntax.all._
 import de.smederee.hub.Generators._
 import de.smederee.hub.config.SmedereeHubConfig
+import de.smederee.security._
 import de.smederee.ssh._
 import doobie._
 import org.flywaydb.core.Flyway
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala	2025-01-31 10:46:28.154967279 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala	2025-01-31 10:46:28.170967306 +0000
@@ -23,6 +23,7 @@
 import cats.syntax.all._
 import de.smederee.hub.Generators._
 import de.smederee.hub.config.SmedereeHubConfig
+import de.smederee.security._
 import doobie._
 import org.flywaydb.core.Flyway
 
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala	2025-01-31 10:46:28.154967279 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala	2025-01-31 10:46:28.170967306 +0000
@@ -22,6 +22,7 @@
 import cats.effect._
 import cats.syntax.all._
 import de.smederee.hub.Generators._
+import de.smederee.security._
 import doobie._
 import org.flywaydb.core.Flyway
 
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 10:46:28.154967279 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-01-31 10:46:28.170967306 +0000
@@ -19,9 +19,11 @@
 
 import cats.effect._
 import cats.syntax.all._
+import de.smederee.email.EmailAddress
 import de.smederee.hub.Generators._
 import de.smederee.hub.VcsMetadataRepositoriesOrdering._
 import de.smederee.hub.config.SmedereeHubConfig
+import de.smederee.security._
 import doobie._
 import org.flywaydb.core.Flyway
 import org.http4s.implicits._
@@ -270,7 +272,7 @@
           Account(
             repo.owner.uid,
             repo.owner.name,
-            Email(s"${repo.owner.name}@example.com"),
+            EmailAddress(s"${repo.owner.name}@example.com"),
             validatedEmail = true
           )
         )
@@ -307,7 +309,7 @@
           Account(
             repo.owner.uid,
             repo.owner.name,
-            Email(s"${repo.owner.name}@example.com"),
+            EmailAddress(s"${repo.owner.name}@example.com"),
             validatedEmail = true
           )
         )
@@ -347,7 +349,7 @@
           Account(
             repo.owner.uid,
             repo.owner.name,
-            Email(s"${repo.owner.name}@example.com"),
+            EmailAddress(s"${repo.owner.name}@example.com"),
             validatedEmail = true
           )
         )
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 10:46:28.154967279 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala	2025-01-31 10:46:28.170967306 +0000
@@ -22,7 +22,8 @@
 import java.util.{ Locale, UUID }
 
 import cats.syntax.all._
-import de.smederee.security.{ PrivateKey, SignAndValidate }
+import de.smederee.email.EmailAddress
+import de.smederee.security._
 
 import org.scalacheck._
 
@@ -80,12 +81,19 @@
 
   val genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID)
 
-  val genValidEmail: Gen[Email] =
-    for {
-      length <- Gen.choose(4, 64)
-      chars  <- Gen.nonEmptyListOf(Gen.alphaNumChar)
-      email = chars.take(length).mkString
-    } yield Email(email + "@example.com")
+  private val validEmailAddressPrefixChars =
+    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.!#$%&’'*+/=?^_`{|}~-".toList
+  private val validDomainNameChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-".toList
+
+  val genValidEmail: Gen[EmailAddress] = for {
+    prefix <- Gen.nonEmptyListOf(Gen.oneOf(validEmailAddressPrefixChars)).map(_.take(64).mkString)
+    domain <- Gen.nonEmptyListOf(Gen.oneOf(validDomainNameChars)).map(_.take(32).mkString)
+    topLevelDomain <- Gen
+      .nonEmptyListOf(Gen.oneOf(validDomainNameChars))
+      .suchThat(_.length >= 2)
+      .map(_.take(24).mkString)
+    suffix = s"$domain.$topLevelDomain"
+  } yield EmailAddress(s"$prefix@$suffix")
 
   val genValidUsername: Gen[Username] = for {
     length <- Gen.choose(2, 30)
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/hub/V1__base_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/hub/V1__base_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/hub/V1__base_tables.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/hub/V1__base_tables.sql	2025-01-31 10:46:28.174967313 +0000
@@ -0,0 +1,88 @@
+CREATE SCHEMA IF NOT EXISTS "hub";
+
+CREATE TABLE "hub"."accounts"
+(
+  "uid"              UUID                     NOT NULL,
+  "name"             CHARACTER VARYING(32)    NOT NULL,
+  "email"            CHARACTER VARYING(128)   NOT NULL,
+  "password"         TEXT,
+  "failed_attempts"  INTEGER                  DEFAULT 0,
+  "locked_at"        TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+  "unlock_token"     TEXT                     DEFAULT NULL,
+  "reset_expiry"     TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+  "reset_token"      TEXT                     DEFAULT NULL,
+  "created_at"       TIMESTAMP WITH TIME ZONE NOT NULL,
+  "updated_at"       TIMESTAMP WITH TIME ZONE NOT NULL,
+  "validated_email"  BOOLEAN                  DEFAULT FALSE,
+  "validation_token" TEXT                     DEFAULT NULL,
+  CONSTRAINT "accounts_pk"           PRIMARY KEY ("uid"),
+  CONSTRAINT "accounts_unique_name"  UNIQUE ("name"),
+  CONSTRAINT "accounts_unique_email" UNIQUE ("email")
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE "hub"."accounts" IS 'All user accounts for the system live within this table.';
+COMMENT ON COLUMN "hub"."accounts"."uid" IS 'A globally unique ID for the related user account.';
+COMMENT ON COLUMN "hub"."accounts"."name" IS 'A username between 2 and 32 characters which must be globally unique, contain only lowercase alphanumeric characters and start with a character.';
+COMMENT ON COLUMN "hub"."accounts"."email" IS 'A globally unique email address associated with the account.';
+COMMENT ON COLUMN "hub"."accounts"."password" IS 'The hashed password for the account.';
+COMMENT ON COLUMN "hub"."accounts"."failed_attempts" IS 'The number of failed login attempts since the last sucessful login.';
+COMMENT ON COLUMN "hub"."accounts"."locked_at" IS 'A timestamp when the account was locked. If this value is not NULL then the account should be considered locked';
+COMMENT ON COLUMN "hub"."accounts"."unlock_token" IS 'An unlock token which can be used to unlock the account.';
+COMMENT ON COLUMN "hub"."accounts"."reset_expiry" IS 'The timestamp when the reset token will expire.';
+COMMENT ON COLUMN "hub"."accounts"."reset_token" IS 'A token which can be used for a password reset.';
+COMMENT ON COLUMN "hub"."accounts"."created_at" IS 'The timestamp of when the account was created.';
+COMMENT ON COLUMN "hub"."accounts"."updated_at" IS 'A timestamp when the account was last changed.';
+COMMENT ON COLUMN "hub"."accounts"."validated_email" IS 'This flag indicates if the email address of the user has been validated via a validation email.';
+COMMENT ON COLUMN "hub"."accounts"."validation_token" IS 'A token used to validate the email address of the user.';
+
+CREATE TABLE "hub"."sessions"
+(
+  "id"         VARCHAR(32)              NOT NULL,
+  "uid"        UUID                     NOT NULL,
+  "created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
+  "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL,
+  CONSTRAINT "sessions_pk"     PRIMARY KEY ("id"),
+  CONSTRAINT "sessions_fk_uid" FOREIGN KEY ("uid")
+    REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE "hub"."sessions" IS 'Keeps the sessions of users.';
+COMMENT ON COLUMN "hub"."sessions"."id" IS 'A globally unique session ID.';
+COMMENT ON COLUMN "hub"."sessions"."uid" IS 'The unique ID of the user account to whom the session belongs.';
+COMMENT ON COLUMN "hub"."sessions"."created_at" IS 'The timestamp of when the session was created.';
+COMMENT ON COLUMN "hub"."sessions"."updated_at" IS 'The session ID should be re-generated in regular intervals resulting in a copy of the old session entry with a new ID and the corresponding timestamp in this column.';
+
+CREATE TABLE "hub"."ssh_keys"
+(
+  "id"           UUID                     NOT NULL, 
+  "uid"          UUID                     NOT NULL,
+  "key_type"     CHARACTER VARYING(32)    NOT NULL,
+  "key"          TEXT                     NOT NULL,
+  "fingerprint"  CHARACTER VARYING(256)   NOT NULL,
+  "comment"      CHARACTER VARYING(256)   DEFAULT NULL,
+  "created_at"   TIMESTAMP WITH TIME ZONE NOT NULL,
+  "last_used_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+  CONSTRAINT "ssh_keys_pk"        PRIMARY KEY ("id"),
+  CONSTRAINT "ssh_keys_unique_fp" UNIQUE ("fingerprint"),
+  CONSTRAINT "ssh_keys_fk_uid"    FOREIGN KEY ("uid")
+    REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE "hub"."ssh_keys" IS 'SSH keys uploaded by users live within this table. Updates are not intended, so keys have to be deleted and re-uploaded upon changes.';
+COMMENT ON COLUMN "hub"."ssh_keys"."id" IS 'The globally unique ID of the ssh key.';
+COMMENT ON COLUMN "hub"."ssh_keys"."uid" IS 'The unique ID of the user account to whom the ssh key belongs.';
+COMMENT ON COLUMN "hub"."ssh_keys"."key_type" IS 'The type of the key e.g. ssh-rsa or ssh-ed25519 and so on.';
+COMMENT ON COLUMN "hub"."ssh_keys"."key" IS 'A base 64 string containing the public ssh key.';
+COMMENT ON COLUMN "hub"."ssh_keys"."fingerprint" IS 'The fingerprint of the ssh key. It must be unique because a key can only be used by one account.';
+COMMENT ON COLUMN "hub"."ssh_keys"."comment" IS 'An optional comment for the ssh key limited to 256 characters.';
+COMMENT ON COLUMN "hub"."ssh_keys"."created_at" IS 'The timestamp of when the ssh key was created.';
+COMMENT ON COLUMN "hub"."ssh_keys"."last_used_at" IS 'The timestamp of when the ssh key was last used by the user.';
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/hub/V2__repository_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/hub/V2__repository_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/hub/V2__repository_tables.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/hub/V2__repository_tables.sql	2025-01-31 10:46:28.174967313 +0000
@@ -0,0 +1,30 @@
+CREATE TABLE "hub"."repositories"
+(
+  "id"          BIGINT                   GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  "name"        CHARACTER VARYING(64)    NOT NULL,
+  "owner"       UUID                     NOT NULL,
+  "is_private"  BOOLEAN                  NOT NULL DEFAULT FALSE,
+  "description" CHARACTER VARYING(254),
+  "vcs_type"    CHARACTER VARYING(16)    NOT NULL,
+  "website"     TEXT,
+  "created_at"  TIMESTAMP WITH TIME ZONE NOT NULL,
+  "updated_at"  TIMESTAMP WITH TIME ZONE NOT NULL,
+  CONSTRAINT "repositories_unique_owner_name" UNIQUE ("owner", "name"),
+  CONSTRAINT "repositories_fk_uid" FOREIGN KEY ("owner")
+    REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE "hub"."repositories" IS 'All repositories i.e. their metadata are stored within this table. The combination of owner id and repository name must always be unique.';
+COMMENT ON COLUMN "hub"."repositories"."id" IS 'An auto generated primary key.';
+COMMENT ON COLUMN "hub"."repositories"."name" IS '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.';
+COMMENT ON COLUMN "hub"."repositories"."owner" IS 'The unique ID of the user account that owns the repository.';
+COMMENT ON COLUMN "hub"."repositories"."is_private" IS 'A flag indicating if this repository is private i.e. only visible / accessible for accounts with appropriate permissions.';
+COMMENT ON COLUMN "hub"."repositories"."description" IS 'An optional short text description of the repository.';
+COMMENT ON COLUMN "hub"."repositories"."vcs_type" IS 'The type of the underlying DVCS that manages the repository.';
+COMMENT ON COLUMN "hub"."repositories"."website" IS 'An optional uri pointing to a website related to the repository / project.';
+COMMENT ON COLUMN "hub"."repositories"."created_at" IS 'The timestamp of when the repository was created.';
+COMMENT ON COLUMN "hub"."repositories"."updated_at" IS 'A timestamp when the repository was last changed.';
+
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/hub/V3__fork_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/hub/V3__fork_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/hub/V3__fork_tables.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/hub/V3__fork_tables.sql	2025-01-31 10:46:28.174967313 +0000
@@ -0,0 +1,18 @@
+CREATE TABLE "hub"."forks"
+(
+  "original_repo" BIGINT NOT NULL,
+  "forked_repo"  BIGINT NOT NULL,
+  CONSTRAINT "forks_pk" PRIMARY KEY ("original_repo", "forked_repo"),
+  CONSTRAINT "forks_fk_original_repo" FOREIGN KEY ("original_repo")
+    REFERENCES "hub"."repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
+  CONSTRAINT "forks_fk_forked_repo" FOREIGN KEY ("forked_repo")
+    REFERENCES "hub"."repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE "hub"."forks" IS 'Stores fork relationships between repositories.';
+COMMENT ON COLUMN "hub"."forks"."original_repo" IS 'The ID of the original repository from which was forked.';
+COMMENT ON COLUMN "hub"."forks"."forked_repo" IS 'The ID of the repository which is the fork.';
+
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql	2025-01-31 10:46:28.154967279 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql	1970-01-01 00:00:00.000000000 +0000
@@ -1,88 +0,0 @@
-CREATE SCHEMA IF NOT EXISTS "hub";
-
-CREATE TABLE "hub"."accounts"
-(
-  "uid"              UUID                     NOT NULL,
-  "name"             CHARACTER VARYING(32)    NOT NULL,
-  "email"            CHARACTER VARYING(128)   NOT NULL,
-  "password"         TEXT,
-  "failed_attempts"  INTEGER                  DEFAULT 0,
-  "locked_at"        TIMESTAMP WITH TIME ZONE DEFAULT NULL,
-  "unlock_token"     TEXT                     DEFAULT NULL,
-  "reset_expiry"     TIMESTAMP WITH TIME ZONE DEFAULT NULL,
-  "reset_token"      TEXT                     DEFAULT NULL,
-  "created_at"       TIMESTAMP WITH TIME ZONE NOT NULL,
-  "updated_at"       TIMESTAMP WITH TIME ZONE NOT NULL,
-  "validated_email"  BOOLEAN                  DEFAULT FALSE,
-  "validation_token" TEXT                     DEFAULT NULL,
-  CONSTRAINT "accounts_pk"           PRIMARY KEY ("uid"),
-  CONSTRAINT "accounts_unique_name"  UNIQUE ("name"),
-  CONSTRAINT "accounts_unique_email" UNIQUE ("email")
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE "hub"."accounts" IS 'All user accounts for the system live within this table.';
-COMMENT ON COLUMN "hub"."accounts"."uid" IS 'A globally unique ID for the related user account.';
-COMMENT ON COLUMN "hub"."accounts"."name" IS 'A username between 2 and 32 characters which must be globally unique, contain only lowercase alphanumeric characters and start with a character.';
-COMMENT ON COLUMN "hub"."accounts"."email" IS 'A globally unique email address associated with the account.';
-COMMENT ON COLUMN "hub"."accounts"."password" IS 'The hashed password for the account.';
-COMMENT ON COLUMN "hub"."accounts"."failed_attempts" IS 'The number of failed login attempts since the last sucessful login.';
-COMMENT ON COLUMN "hub"."accounts"."locked_at" IS 'A timestamp when the account was locked. If this value is not NULL then the account should be considered locked';
-COMMENT ON COLUMN "hub"."accounts"."unlock_token" IS 'An unlock token which can be used to unlock the account.';
-COMMENT ON COLUMN "hub"."accounts"."reset_expiry" IS 'The timestamp when the reset token will expire.';
-COMMENT ON COLUMN "hub"."accounts"."reset_token" IS 'A token which can be used for a password reset.';
-COMMENT ON COLUMN "hub"."accounts"."created_at" IS 'The timestamp of when the account was created.';
-COMMENT ON COLUMN "hub"."accounts"."updated_at" IS 'A timestamp when the account was last changed.';
-COMMENT ON COLUMN "hub"."accounts"."validated_email" IS 'This flag indicates if the email address of the user has been validated via a validation email.';
-COMMENT ON COLUMN "hub"."accounts"."validation_token" IS 'A token used to validate the email address of the user.';
-
-CREATE TABLE "hub"."sessions"
-(
-  "id"         VARCHAR(32)              NOT NULL,
-  "uid"        UUID                     NOT NULL,
-  "created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
-  "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL,
-  CONSTRAINT "sessions_pk"     PRIMARY KEY ("id"),
-  CONSTRAINT "sessions_fk_uid" FOREIGN KEY ("uid")
-    REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE "hub"."sessions" IS 'Keeps the sessions of users.';
-COMMENT ON COLUMN "hub"."sessions"."id" IS 'A globally unique session ID.';
-COMMENT ON COLUMN "hub"."sessions"."uid" IS 'The unique ID of the user account to whom the session belongs.';
-COMMENT ON COLUMN "hub"."sessions"."created_at" IS 'The timestamp of when the session was created.';
-COMMENT ON COLUMN "hub"."sessions"."updated_at" IS 'The session ID should be re-generated in regular intervals resulting in a copy of the old session entry with a new ID and the corresponding timestamp in this column.';
-
-CREATE TABLE "hub"."ssh_keys"
-(
-  "id"           UUID                     NOT NULL, 
-  "uid"          UUID                     NOT NULL,
-  "key_type"     CHARACTER VARYING(32)    NOT NULL,
-  "key"          TEXT                     NOT NULL,
-  "fingerprint"  CHARACTER VARYING(256)   NOT NULL,
-  "comment"      CHARACTER VARYING(256)   DEFAULT NULL,
-  "created_at"   TIMESTAMP WITH TIME ZONE NOT NULL,
-  "last_used_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL,
-  CONSTRAINT "ssh_keys_pk"        PRIMARY KEY ("id"),
-  CONSTRAINT "ssh_keys_unique_fp" UNIQUE ("fingerprint"),
-  CONSTRAINT "ssh_keys_fk_uid"    FOREIGN KEY ("uid")
-    REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE "hub"."ssh_keys" IS 'SSH keys uploaded by users live within this table. Updates are not intended, so keys have to be deleted and re-uploaded upon changes.';
-COMMENT ON COLUMN "hub"."ssh_keys"."id" IS 'The globally unique ID of the ssh key.';
-COMMENT ON COLUMN "hub"."ssh_keys"."uid" IS 'The unique ID of the user account to whom the ssh key belongs.';
-COMMENT ON COLUMN "hub"."ssh_keys"."key_type" IS 'The type of the key e.g. ssh-rsa or ssh-ed25519 and so on.';
-COMMENT ON COLUMN "hub"."ssh_keys"."key" IS 'A base 64 string containing the public ssh key.';
-COMMENT ON COLUMN "hub"."ssh_keys"."fingerprint" IS 'The fingerprint of the ssh key. It must be unique because a key can only be used by one account.';
-COMMENT ON COLUMN "hub"."ssh_keys"."comment" IS 'An optional comment for the ssh key limited to 256 characters.';
-COMMENT ON COLUMN "hub"."ssh_keys"."created_at" IS 'The timestamp of when the ssh key was created.';
-COMMENT ON COLUMN "hub"."ssh_keys"."last_used_at" IS 'The timestamp of when the ssh key was last used by the user.';
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql	2025-01-31 10:46:28.154967279 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql	1970-01-01 00:00:00.000000000 +0000
@@ -1,30 +0,0 @@
-CREATE TABLE "hub"."repositories"
-(
-  "id"          BIGINT                   GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-  "name"        CHARACTER VARYING(64)    NOT NULL,
-  "owner"       UUID                     NOT NULL,
-  "is_private"  BOOLEAN                  NOT NULL DEFAULT FALSE,
-  "description" CHARACTER VARYING(254),
-  "vcs_type"    CHARACTER VARYING(16)    NOT NULL,
-  "website"     TEXT,
-  "created_at"  TIMESTAMP WITH TIME ZONE NOT NULL,
-  "updated_at"  TIMESTAMP WITH TIME ZONE NOT NULL,
-  CONSTRAINT "repositories_unique_owner_name" UNIQUE ("owner", "name"),
-  CONSTRAINT "repositories_fk_uid" FOREIGN KEY ("owner")
-    REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE "hub"."repositories" IS 'All repositories i.e. their metadata are stored within this table. The combination of owner id and repository name must always be unique.';
-COMMENT ON COLUMN "hub"."repositories"."id" IS 'An auto generated primary key.';
-COMMENT ON COLUMN "hub"."repositories"."name" IS '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.';
-COMMENT ON COLUMN "hub"."repositories"."owner" IS 'The unique ID of the user account that owns the repository.';
-COMMENT ON COLUMN "hub"."repositories"."is_private" IS 'A flag indicating if this repository is private i.e. only visible / accessible for accounts with appropriate permissions.';
-COMMENT ON COLUMN "hub"."repositories"."description" IS 'An optional short text description of the repository.';
-COMMENT ON COLUMN "hub"."repositories"."vcs_type" IS 'The type of the underlying DVCS that manages the repository.';
-COMMENT ON COLUMN "hub"."repositories"."website" IS 'An optional uri pointing to a website related to the repository / project.';
-COMMENT ON COLUMN "hub"."repositories"."created_at" IS 'The timestamp of when the repository was created.';
-COMMENT ON COLUMN "hub"."repositories"."updated_at" IS 'A timestamp when the repository was last changed.';
-
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql	1970-01-01 00:00:00.000000000 +0000
@@ -1,18 +0,0 @@
-CREATE TABLE "hub"."forks"
-(
-  "original_repo" BIGINT NOT NULL,
-  "forked_repo"  BIGINT NOT NULL,
-  CONSTRAINT "forks_pk" PRIMARY KEY ("original_repo", "forked_repo"),
-  CONSTRAINT "forks_fk_original_repo" FOREIGN KEY ("original_repo")
-    REFERENCES "hub"."repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
-  CONSTRAINT "forks_fk_forked_repo" FOREIGN KEY ("forked_repo")
-    REFERENCES "hub"."repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE "hub"."forks" IS 'Stores fork relationships between repositories.';
-COMMENT ON COLUMN "hub"."forks"."original_repo" IS 'The ID of the original repository from which was forked.';
-COMMENT ON COLUMN "hub"."forks"."forked_repo" IS 'The ID of the repository which is the fork.';
-
diff -rN -u old-smederee/modules/hub/src/main/resources/reference.conf new-smederee/modules/hub/src/main/resources/reference.conf
--- old-smederee/modules/hub/src/main/resources/reference.conf	2025-01-31 10:46:28.154967279 +0000
+++ new-smederee/modules/hub/src/main/resources/reference.conf	2025-01-31 10:46:28.174967313 +0000
@@ -2,163 +2,165 @@
 ###       Reference configuration file for the Smederee hub service.        ###
 ###############################################################################
 
-# Configuration of the database.
-# Defaults are given except for password and can also be overridden via
-# environment variables.
-database {
-  # The class name of the JDBC driver to be used.
-  driver = "org.postgresql.Driver"
-  driver = ${?SMEDEREE_HUB_DB_DRIVER}
-  # The JDBC connection URL **without** username and password.
-  url    = "jdbc:postgresql://localhost:5432/smederee"
-  url    = ${?SMEDEREE_HUB_DB_URL}
-  # The username (login) needed to authenticate against the database.
-  user   = "smederee_hub"
-  user   = ${?SMEDEREE_HUB_DB_USER}
-  # The password needed to authenticate against the database.
-  pass   = ${?SMEDEREE_HUB_DB_PASS}
-}
-
-# The general service configuration.
-# Settings which toggle something on or off are booleans (true / false).
-service {
-  # The hostname on which the service shall listen for requests.
-  host = "localhost"
-  # The TCP port number on which the service shall listen for requests.
-  port = 8080
-  # A directory into which files are written that are supposed to be downloaded by users (e.g. distribution
-  # files of repositories).
-  download-directory = /var/tmp/smederee/download
-  download-directory = ${?SMEDEREE_DOWNLOAD_DIR}
-  # A file which contains the key used to build the CSRF protection.
-  # If it does not exist then it should be created with sensible permissions.
-  csrf-key-file = /var/tmp/smederee/csrf-key.bin
-  csrf-key-file = ${?SMEDEREE_CSRF_KEY_FILE}
-
-  # Settings affecting how the service will communicate several information to
-  # the "outside world" e.g. if it runs behind a reverse proxy.
-  external {
-    # The official hostname of the service which will be used for the CSRF
-    # protection, generation of links in e-mails etc.
-    host = ${service.host}
-
-    # A possible path prefix that will be prepended to any paths used in link
-    # generation. If no path prefix is used then you MUST either comment it out
-    # or set it to `path = null`!
-    #path = null
-    
-    # The port number which defaults to the port the service is listening on.
-    # Please note that this is also relevant for CSRF protection!
-    # If the service is running behind a reverse proxy on a standard port e.g.
-    # 80 or 443 (http or https) then you MUST set this either to `port = null`
-    # or comment it out!
-    port = ${service.port}
-
-    # The URL scheme which is used for links and will also determine if cookies
-    # will have the secure flag enabled.
-    # Valid options are:
-    # - http
-    # - https
-    scheme = "http"
-  }
-
-  # Authentication / login settings
-  authentication {
-    enabled = true
-
-    # The secret used for the cookie encryption and validation.
-    # Using the default should produce a warning message on startup.
-    cookie-secret = "CHANGEME"
-
-    # Determines after how many failed login attempts an account gets locked.
-    lock-after = 5
-
-    # Timeouts for the authentication session.
-    timeouts {
-      # The maximum allowed age an authentication session. This setting will
-      # affect the invalidation of a session on the server side.
-      # This timeout MUST be triggered regardless of session activity.
-      absolute-timeout = 3 days
-
-      # This timeout defines how long after the last activity a session will
-      # remain valid.
-      idle-timeout = 30 minutes
-
-      # The time after which a session will be renewed (a new session ID will be
-      # generated).
-      renewal-timeout = 20 minutes
-    }
-  }
-
-  # Billing / payment related settings
-  billing {
-    enabled = false
-
-    # Settings for the Stripe API used for billing.
-    stripe {
-      api-key    = ${?STRIPE_API_KEY}
-      secret-key = ${?STRIPE_SECRET_KEY}
-    }
-  }
-
-  # Configuration for the darcs module for vcs related operations via darcs.
-  darcs {
-    # The directory used to store the actual repositories structured after owner.
-    # ```
-    # repositories-directory
-    #   \_ user1
-    #        \_ repo1
-    #        \_ repo2
-    #   \_ user2
-    #        \_ repo1
-    # ```
-    repositories-directory = /srv/smederee/darcs
-    repositories-directory = ${?SMEDEREE_DARCS_REPOS_DIR}
-    # The path to the darcs binary executable. If not a full path (i.e. just
-    # `darcs`) it must be present on the `$PATH` of the environment under which
-    # the server is running.
-    executable = "darcs"
-    executable = ${?SMEDEREE_DARCS_EXECUTABLE}
-  }
-
-  # The email middleware configuration for sending email messages.
-  email {
-    # The hostname of the email server (SMTP) to connect to.
-    host = "localhost"
-    host = ${?EMAIL_HOST}
-    # The port number to be used for the connection.
-    # This is usually 25 for local sendmail connections and 465 for SMTPS or 587 SMTP_TLS connections.
-    port = 25
-    port = ${?EMAIL_PORT}
-    # Specify the transport method (security) to be used for the connection (should either be SMTPS or TLS).
-    transport = "PLAIN"
-    transport = ${?EMAIL_TRANSPORT}
-    # An optional username if authentication is required.
-    username = ${?EMAIL_USERNAME}
-    # An optional password if authentication is required.
-    password = ${?EMAIL_PASSWORD}
-  }
-
-  # SSH server component settings
-  ssh {
-    enabled = false
-    # A username for generic access to services for darcs clone, pull and push 
-    # (e.g. `darcs pull genericUser@smederee-domain:accountName/repository`).
-    generic-user = "darcs"
-    # The hostname/address the SSH server will bind to.
-    host = "localhost"
-    host = ${?SSH_SERVER_HOST}
-    # The port number on which the SSH server will listen.
-    port = 30983
-    port = ${?SSH_SERVER_PORT}
-    # A path to the file from which the server key is loaded and also written to if it needs to be generated.
-    # This file should only be accessible for the user account that runs the smederee service.
-    server-key-file = /var/db/smederee/server.key
-    server-key-file = ${?SSH_SERVER_KEY}
-  }
-
-  # Signup / registration related settings.
-  signup {
-    enabled = true
+hub {
+  # Configuration of the database.
+  # Defaults are given except for password and can also be overridden via
+  # environment variables.
+  database {
+	# The class name of the JDBC driver to be used.
+	driver = "org.postgresql.Driver"
+	driver = ${?SMEDEREE_HUB_DB_DRIVER}
+	# The JDBC connection URL **without** username and password.
+	url    = "jdbc:postgresql://localhost:5432/smederee"
+	url    = ${?SMEDEREE_HUB_DB_URL}
+	# The username (login) needed to authenticate against the database.
+	user   = "smederee_hub"
+	user   = ${?SMEDEREE_HUB_DB_USER}
+	# The password needed to authenticate against the database.
+	pass   = ${?SMEDEREE_HUB_DB_PASS}
+  }
+
+  # The general service configuration.
+  # Settings which toggle something on or off are booleans (true / false).
+  service {
+	# The hostname on which the service shall listen for requests.
+	host = "localhost"
+	# The TCP port number on which the service shall listen for requests.
+	port = 8080
+	# A directory into which files are written that are supposed to be downloaded by users (e.g. distribution
+	# files of repositories).
+	download-directory = /var/tmp/smederee/download
+	download-directory = ${?SMEDEREE_DOWNLOAD_DIR}
+	# A file which contains the key used to build the CSRF protection.
+	# If it does not exist then it should be created with sensible permissions.
+	csrf-key-file = /var/tmp/smederee/csrf-key.bin
+	csrf-key-file = ${?SMEDEREE_CSRF_KEY_FILE}
+
+	# Settings affecting how the service will communicate several information to
+	# the "outside world" e.g. if it runs behind a reverse proxy.
+	external {
+	  # The official hostname of the service which will be used for the CSRF
+	  # protection, generation of links in e-mails etc.
+	  host = ${hub.service.host}
+
+	  # A possible path prefix that will be prepended to any paths used in link
+	  # generation. If no path prefix is used then you MUST either comment it out
+	  # or set it to `path = null`!
+	  #path = null
+	  
+	  # The port number which defaults to the port the service is listening on.
+	  # Please note that this is also relevant for CSRF protection!
+	  # If the service is running behind a reverse proxy on a standard port e.g.
+	  # 80 or 443 (http or https) then you MUST set this either to `port = null`
+	  # or comment it out!
+	  port = ${hub.service.port}
+
+	  # The URL scheme which is used for links and will also determine if cookies
+	  # will have the secure flag enabled.
+	  # Valid options are:
+	  # - http
+	  # - https
+	  scheme = "http"
+	}
+
+	# Authentication / login settings
+	authentication {
+	  enabled = true
+
+	  # The secret used for the cookie encryption and validation.
+	  # Using the default should produce a warning message on startup.
+	  cookie-secret = "CHANGEME"
+
+	  # Determines after how many failed login attempts an account gets locked.
+	  lock-after = 5
+
+	  # Timeouts for the authentication session.
+	  timeouts {
+		# The maximum allowed age an authentication session. This setting will
+		# affect the invalidation of a session on the server side.
+		# This timeout MUST be triggered regardless of session activity.
+		absolute-timeout = 3 days
+
+		# This timeout defines how long after the last activity a session will
+		# remain valid.
+		idle-timeout = 30 minutes
+
+		# The time after which a session will be renewed (a new session ID will be
+		# generated).
+		renewal-timeout = 20 minutes
+	  }
+	}
+
+	# Billing / payment related settings
+	billing {
+	  enabled = false
+
+	  # Settings for the Stripe API used for billing.
+	  stripe {
+		api-key    = ${?STRIPE_API_KEY}
+		secret-key = ${?STRIPE_SECRET_KEY}
+	  }
+	}
+
+	# Configuration for the darcs module for vcs related operations via darcs.
+	darcs {
+	  # The directory used to store the actual repositories structured after owner.
+	  # ```
+	  # repositories-directory
+	  #   \_ user1
+	  #        \_ repo1
+	  #        \_ repo2
+	  #   \_ user2
+	  #        \_ repo1
+	  # ```
+	  repositories-directory = /srv/smederee/darcs
+	  repositories-directory = ${?SMEDEREE_DARCS_REPOS_DIR}
+	  # The path to the darcs binary executable. If not a full path (i.e. just
+	  # `darcs`) it must be present on the `$PATH` of the environment under which
+	  # the server is running.
+	  executable = "darcs"
+	  executable = ${?SMEDEREE_DARCS_EXECUTABLE}
+	}
+
+	# The email middleware configuration for sending email messages.
+	email {
+	  # The hostname of the email server (SMTP) to connect to.
+	  host = "localhost"
+	  host = ${?EMAIL_HOST}
+	  # The port number to be used for the connection.
+	  # This is usually 25 for local sendmail connections and 465 for SMTPS or 587 SMTP_TLS connections.
+	  port = 25
+	  port = ${?EMAIL_PORT}
+	  # Specify the transport method (security) to be used for the connection (should either be SMTPS or TLS).
+	  transport = "PLAIN"
+	  transport = ${?EMAIL_TRANSPORT}
+	  # An optional username if authentication is required.
+	  username = ${?EMAIL_USERNAME}
+	  # An optional password if authentication is required.
+	  password = ${?EMAIL_PASSWORD}
+	}
+
+	# SSH server component settings
+	ssh {
+	  enabled = false
+	  # A username for generic access to services for darcs clone, pull and push 
+	  # (e.g. `darcs pull genericUser@smederee-domain:accountName/repository`).
+	  generic-user = "darcs"
+	  # The hostname/address the SSH server will bind to.
+	  host = "localhost"
+	  host = ${?SSH_SERVER_HOST}
+	  # The port number on which the SSH server will listen.
+	  port = 30983
+	  port = ${?SSH_SERVER_PORT}
+	  # A path to the file from which the server key is loaded and also written to if it needs to be generated.
+	  # This file should only be accessible for the user account that runs the smederee service.
+	  server-key-file = /var/db/smederee/server.key
+	  server-key-file = ${?SSH_SERVER_KEY}
+	}
+
+	# Signup / registration related settings.
+	signup {
+	  enabled = true
+	}
   }
 }
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 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala	2025-01-31 10:46:28.174967313 +0000
@@ -19,6 +19,7 @@
 
 import java.util.UUID
 
+import de.smederee.security._
 import de.smederee.ssh.PublicSshKey
 import fs2.Stream
 
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 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	2025-01-31 10:46:28.174967313 +0000
@@ -31,7 +31,7 @@
 import de.smederee.hub.RequestHelpers.instances.given
 import de.smederee.hub.config._
 import de.smederee.hub.forms.types._
-import de.smederee.security.SignAndValidate
+import de.smederee.security._
 import de.smederee.ssh._
 import org.http4s._
 import org.http4s.dsl._
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 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala	2025-01-31 10:46:28.174967313 +0000
@@ -18,133 +18,24 @@
 package de.smederee.hub
 
 import java.nio.charset.StandardCharsets
-import java.util.UUID
 
 import cats._
 import cats.data._
 import cats.syntax.all._
-import de.smederee.email.{ FromAddress, ToAddress }
+import de.smederee.email.EmailAddress
+import de.smederee.security._
+import de.smederee.tickets.ProjectOwner
 import org.springframework.security.crypto.argon2.Argon2PasswordEncoder
 
 import scala.util.matching.Regex
 
-/** An email address must fulfil several format requirements which in detail should be looked up in the implementation.
-  */
-opaque type Email = String
-object Email {
-  given Eq[Email] = Eq.fromUniversalEquals
-
-  val validateString: Regex =
-    """^([a-zA-Z0-9.!#$%&’'*+/=?^_`{|}~-]+)@([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*)$""".r
-
-  /** Create an instance of Email from the given String type.
-    *
-    * @param source
-    *   An instance of type String which will be returned as a Email.
-    * @return
-    *   The appropriate instance of Email.
-    */
-  def apply(source: String): Email = source
-
-  /** Try to create an instance of Email from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a Email.
-    * @return
-    *   An option to the successfully converted Email.
-    */
-  def from(source: String): Option[Email] = {
-    // Must obviously not be null or empty.
-    val notEmpty = Option(source)
-    // Must have at least one '@' sign.
-    val hasAtChar = Option(source).filter(_.exists(_ === '@'))
-    // Must have at least one '.' character.
-    val hasDotChar = Option(source).filter(_.exists(_ === '.'))
-    // The maximum allowed length is 128 characters.
-    val belowMaxLength = Option(source).filter(_.length <= 128)
-    // The last part of the email address (usually the top level domain) must at least be 2 characters long.
-    val lastPartValid = Option(source) match {
-      case None => None
-      case Some(string) =>
-        val parts = string.split('.')
-        if (parts.lastOption.map(_.length >= 2).getOrElse(false))
-          Option(string)
-        else
-          None
-    }
-    // Check against a regular expression. TODO Maybe we can skip the other tests alltogether?
-    val passesRegularExpression = Option(source).filter(validateString.matches)
-    // Validate pre-conditions
-    (notEmpty, hasAtChar, hasDotChar, belowMaxLength, lastPartValid, passesRegularExpression).mapN {
-      case (email, _, _, _, _, _) => email
-    }
-  }
-
-  extension (email: Email) {
-
-    /** Convert the email into a [[de.smederee.email.FromAddress]] to be useable within the email middleware.
-      *
-      * @return
-      *   The email address to be used in a `From` header.
-      */
-    def toFromAddress: FromAddress = FromAddress(email.toString)
-
-    /** Convert the email into a [[de.smederee.email.ToAddress]] to be useable within the email middleware.
-      *
-      * @return
-      *   The email address to be used in a `To` header.
-      */
-    def toToAddress: ToAddress = ToAddress(email.toString)
-  }
-
-}
-
-/** A password is stored as an `Array[Byte]` internally and its `validate(source: String)` function will check that the
-  * input has a minimum length.
+/** Extension methods for a password instance.
+  *
+  * TODO: Refactor this into the security package!
+  *
+  * @param p
+  *   A password.
   */
-opaque type Password = Array[Byte]
-object Password {
-
-  /** Create an instance of Password from the given Array[Byte] type.
-    *
-    * @param source
-    *   An instance of type Array[Byte] which will be returned as a Password.
-    * @return
-    *   The appropriate instance of Password.
-    */
-  def apply(source: Array[Byte]): Password = source
-
-  /** Try to create an instance of Password from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a Password.
-    * @return
-    *   An option to the successfully converted Password.
-    */
-  def from(source: String): Option[Password] =
-    Option(source) match {
-      case None         => None
-      case Some(string) => Option(string.getBytes(StandardCharsets.UTF_8))
-    }
-
-  /** Validate the given String against the criteria for a valid password.
-    *
-    * @param source
-    *   A string which should fulfil the password criteria.
-    * @return
-    *   Either a list of errors or the validated Password.
-    */
-  def validate(source: String): ValidatedNec[String, Password] =
-    Option(source).map(_.trim.nonEmpty) match {
-      case Some(true) =>
-        if (source.trim.length < 12)
-          "Password must be at least 12 characters long!".invalidNec
-        else
-          source.trim.getBytes(StandardCharsets.UTF_8).validNec
-      case _ => "Password must not be empty!".invalidNec
-    }
-}
-
 extension (p: Password) {
 
   /** Encode the password using the argon2 algorithm.
@@ -154,7 +45,7 @@
     */
   def encode: PasswordHash = {
     val encoder = PasswordEncoder.Argon2
-    PasswordHash(encoder.encode(new String(p, StandardCharsets.UTF_8)))
+    PasswordHash(encoder.encode(new String(p.toArray, StandardCharsets.UTF_8)))
   }
 
   /** Verify if the password matches the given `PasswordHash`.
@@ -166,10 +57,8 @@
     */
   def matches(hash: PasswordHash): Boolean = {
     val encoder = PasswordEncoder.Argon2
-    encoder.matches(new String(p, StandardCharsets.UTF_8), hash)
+    encoder.matches(new String(p.toArray, StandardCharsets.UTF_8), hash.toString)
   }
-
-  def toArray: Array[Byte] = p
 }
 
 /** Initialises and holds our preferred password encoding algorithm.
@@ -186,33 +75,6 @@
 
 }
 
-opaque type PasswordHash = String
-object PasswordHash {
-  given Eq[PasswordHash] = Eq.fromUniversalEquals
-
-  /** Create an instance of PasswordHash from the given String type.
-    *
-    * @param source
-    *   An instance of type String which will be returned as a PasswordHash.
-    * @return
-    *   The appropriate instance of PasswordHash.
-    */
-  def apply(source: String): PasswordHash = source
-
-  /** Try to create an instance of PasswordHash from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a PasswordHash.
-    * @return
-    *   An option to the successfully converted PasswordHash.
-    */
-  def from(source: String): Option[PasswordHash] =
-    Option(source).map(_.trim.nonEmpty) match {
-      case Some(true) => Option(source.trim)
-      case _          => None
-    }
-}
-
 opaque type ResetToken = String
 object ResetToken {
   val Format: Regex = "^[a-zA-z0-9]+".r
@@ -281,67 +143,6 @@
 
 }
 
-/** A username for an account has to obey several restrictions which are similiar to the ones found for Unix usernames.
-  * It must start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and
-  * be all lowercase.
-  */
-opaque type Username = String
-object Username {
-  given Eq[Username] = Eq.fromUniversalEquals
-
-  val isAlphanumeric = "^[a-z][a-z0-9]+$".r
-
-  /** Create an instance of Username from the given String type.
-    *
-    * @param source
-    *   An instance of type String which will be returned as a Username.
-    * @return
-    *   The appropriate instance of Username.
-    */
-  def apply(source: String): Username = source
-
-  /** Try to create an instance of Username from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a Username.
-    * @return
-    *   An option to the successfully converted Username.
-    */
-  def from(s: String): Option[Username] = validate(s).toOption
-
-  /** Validate the given string and return either the validated username or a list of errors.
-    *
-    * @param s
-    *   An arbitrary string which should be a username.
-    * @return
-    *   Either a list of errors or the validated username.
-    */
-  def validate(s: String): ValidatedNec[String, Username] =
-    Option(s).map(_.trim.nonEmpty) match {
-      case Some(true) =>
-        val input = s.trim
-        val miniumLength =
-          if (input.length >= 2)
-            input.validNec
-          else
-            "Username too short (min. 2 characters)!".invalidNec
-        val maximumLength =
-          if (input.length < 32)
-            input.validNec
-          else
-            "Username too long (max. 31 characters)!".invalidNec
-        val alphanumeric =
-          if (isAlphanumeric.matches(input))
-            input.validNec
-          else
-            "Username must be all lowercase alphanumeric characters and start with a character.".invalidNec
-        (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) =>
-          name
-        }
-      case _ => "Username must not be empty!".invalidNec
-    }
-}
-
 /** Extractor to retrieve an Username from a path parameter.
   */
 object UsernamePathParameter {
@@ -406,7 +207,7 @@
   * @param validatedEmail
   *   This flag indicates if the email address of the user has been validated via a validation email.
   */
-final case class Account(uid: UserId, name: Username, email: Email, validatedEmail: Boolean)
+final case class Account(uid: UserId, name: Username, email: EmailAddress, validatedEmail: Boolean)
 
 object Account {
   given Eq[Account] =
@@ -416,6 +217,13 @@
 
   extension (account: Account) {
 
+    /** Create project owner metadata from the account.
+      *
+      * @return
+      *   Descriptive information about the owner of a project based on the account.
+      */
+    def toProjectOwner: ProjectOwner = ProjectOwner(account.uid, account.name, account.email)
+
     /** Create vcs repository owner metadata from the account.
       *
       * @return
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationMiddleware.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationMiddleware.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationMiddleware.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationMiddleware.scala	2025-01-31 10:46:28.174967313 +0000
@@ -18,7 +18,6 @@
 package de.smederee.hub
 
 import java.time._
-import java.util.UUID
 
 import cats.data._
 import cats.effect._
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala	2025-01-31 10:46:28.174967313 +0000
@@ -17,6 +17,9 @@
 
 package de.smederee.hub
 
+import de.smederee.email.EmailAddress
+import de.smederee.security._
+
 /** A base class for database functionality related to the authentication process.
   *
   * ### General notes ###
@@ -67,7 +70,7 @@
     * @return
     *   An option to the found account if it exists.
     */
-  def findAccountByEmail(email: Email): F[Option[Account]]
+  def findAccountByEmail(email: EmailAddress): F[Option[Account]]
 
   /** Search for the unlocked account with the given name in the database and return the first found result.
     *
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala	2025-01-31 10:46:28.174967313 +0000
@@ -18,26 +18,23 @@
 package de.smederee.hub
 
 import java.time.{ OffsetDateTime, ZoneOffset }
-import java.util.UUID
 
 import cats.data._
 import cats.effect._
 import cats.syntax.all._
 import de.smederee.hub.RequestHelpers.instances.given_RequestHelpers_Request
 import de.smederee.hub.SessionHelpers.instances.toAuthenticationCookie
+import de.smederee.hub._
 import de.smederee.hub.config._
 import de.smederee.hub.forms.types.FormErrors
 import de.smederee.hub.forms.types.FormFieldError
-import de.smederee.hub.views
-import de.smederee.security.SignAndValidate
-import org.http4s.FormDataDecoder._
+import de.smederee.security._
 import org.http4s._
 import org.http4s.dsl.Http4sDsl
 import org.http4s.headers.Location
 import org.http4s.implicits._
 import org.http4s.twirl.TwirlInstances._
 import org.slf4j.LoggerFactory
-import play.twirl.api._
 
 /** Enumeration of possible kinds of authentication failures.
   */
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/config/ConfigurationPath.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/config/ConfigurationPath.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/config/ConfigurationPath.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/config/ConfigurationPath.scala	2025-01-31 10:46:28.174967313 +0000
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub.config
+
+/** A configuration path describes a path within a configuration file and is used to determine locations of certain
+  * configurations within a combined configuration file.
+  */
+opaque type ConfigurationPath = String
+object ConfigurationPath {
+
+  given Conversion[ConfigurationPath, String] = _.toString
+
+  /** Create an instance of ConfigurationPath from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a ConfigurationPath.
+    * @return
+    *   The appropriate instance of ConfigurationPath.
+    */
+  def apply(source: String): ConfigurationPath = source
+
+  /** Try to create an instance of ConfigurationPath from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a ConfigurationPath.
+    * @return
+    *   An option to the successfully converted ConfigurationPath.
+    */
+  def from(source: String): Option[ConfigurationPath] = Option(source).map(_.trim).filter(_.nonEmpty)
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala	2025-01-31 10:46:28.174967313 +0000
@@ -21,45 +21,16 @@
 import java.nio.file._
 
 import cats.kernel.Eq
-import cats.syntax.all._
 import com.comcast.ip4s.{ Host, Port }
 import de.smederee.email._
 import de.smederee.html.ExternalUrlConfiguration
 import de.smederee.security._
 import de.smederee.ssh._
 import org.http4s.Uri
-import org.slf4j.LoggerFactory
 import pureconfig._
 
 import scala.concurrent.duration.FiniteDuration
 import scala.util.Try
-import scala.util.matching.Regex
-
-opaque type ConfigKey = String
-object ConfigKey {
-
-  /** Create an instance of ConfigKey from the given String type.
-    *
-    * @param source
-    *   An instance of type String which will be returned as a ConfigKey.
-    * @return
-    *   The appropriate instance of ConfigKey.
-    */
-  def apply(source: String): ConfigKey = source
-
-  /** Try to create an instance of ConfigKey from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a ConfigKey.
-    * @return
-    *   An option to the successfully converted ConfigKey.
-    */
-  def from(source: String): Option[ConfigKey] =
-    Option(source).map(_.trim.nonEmpty) match {
-      case Some(true) => Option(source.trim)
-      case _          => None
-    }
-}
 
 opaque type CookieName = String
 object CookieName {
@@ -87,32 +58,6 @@
     }
 }
 
-opaque type CsrfToken = String
-object CsrfToken {
-
-  /** Create an instance of CsrfToken from the given String type.
-    *
-    * @param source
-    *   An instance of type String which will be returned as a CsrfToken.
-    * @return
-    *   The appropriate instance of CsrfToken.
-    */
-  def apply(source: String): CsrfToken = source
-
-  /** Try to create an instance of CsrfToken from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a CsrfToken.
-    * @return
-    *   An option to the successfully converted CsrfToken.
-    */
-  def from(source: String): Option[CsrfToken] =
-    Option(source).map(_.trim.nonEmpty) match {
-      case Some(true) => Option(source.trim)
-      case _          => None
-    }
-}
-
 opaque type DirectoryPath = Path
 object DirectoryPath {
 
@@ -180,44 +125,6 @@
 
 }
 
-opaque type LanguageCode = String
-object LanguageCode {
-  val isIso639: Regex = "^[a-z]{2,3}$".r
-
-  /** Create an instance of LanguageCode from the given String type.
-    *
-    * @param source
-    *   An instance of type String which will be returned as a LanguageCode.
-    * @return
-    *   The appropriate instance of LanguageCode.
-    */
-  def apply(source: String): LanguageCode = source
-
-  /** Try to create an instance of LanguageCode from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a LanguageCode.
-    * @return
-    *   An option to the successfully converted LanguageCode.
-    */
-  def from(source: String): Option[LanguageCode] =
-    Option(source).map(isIso639.matches) match {
-      case Some(true) => Option(source)
-      case _          => None
-    }
-
-  extension (code: LanguageCode) {
-
-    /** Convert the language code into a [[java.util.Locale]] to be used for internationalisation functions.
-      *
-      * @return
-      *   A locale retrieved via the `forLanguageTag` method.
-      */
-    def toLocale: java.util.Locale = java.util.Locale.forLanguageTag(code.toString)
-  }
-
-}
-
 /** Global constants which are used throughout the code.
   */
 object Constants {
@@ -236,6 +143,8 @@
 final case class SmedereeHubConfig(database: DatabaseConfig, service: ServiceConfig)
 
 object SmedereeHubConfig {
+  val location: ConfigurationPath = ConfigurationPath("hub")
+
   given Eq[SmedereeHubConfig] = Eq.fromUniversalEquals
 
   given ConfigReader[SmedereeHubConfig] = ConfigReader.forProduct2("database", "service")(SmedereeHubConfig.apply)
@@ -255,11 +164,7 @@
 final case class DatabaseConfig(driver: String, url: String, user: String, pass: String)
 
 object DatabaseConfig {
-  // The default configuration key under which to lookup the database configuration.
-  final val parentKey: ConfigKey = ConfigKey("database")
-
-  given Eq[DatabaseConfig] = Eq.fromUniversalEquals
-
+  given Eq[DatabaseConfig]           = Eq.fromUniversalEquals
   given ConfigReader[DatabaseConfig] = ConfigReader.forProduct4("driver", "url", "user", "pass")(DatabaseConfig.apply)
 }
 
@@ -306,9 +211,6 @@
 )
 
 object ServiceConfig {
-  // The default configuration key under which to lookup the service configuration.
-  final val parentKey: ConfigKey = ConfigKey("service")
-
   given Eq[ServiceConfig] = Eq.fromUniversalEquals
 
   given ConfigReader[Host]       = ConfigReader.fromStringOpt[Host](Host.fromString)
@@ -337,13 +239,13 @@
       "port",
       "csrf-key-file",
       "download-directory",
-      AuthenticationConfiguration.parentKey.toString,
-      BillingConfiguration.parentKey.toString,
-      DarcsConfiguration.parentKey.toString,
+      "authentication",
+      "billing",
+      "darcs",
       "email",
       "external",
-      SignupConfiguration.parentKey.toString,
-      SshServerConfiguration.parentKey.toString
+      "signup",
+      "ssh"
     )(ServiceConfig.apply)
 }
 
@@ -364,14 +266,10 @@
 )
 
 object AuthenticationTimeouts {
-  // The default configuration key under which to lookup the billing configuration.
-  final val parentKey: ConfigKey = ConfigKey("timeouts")
-
   given ConfigReader[AuthenticationTimeouts] =
     ConfigReader.forProduct3("absolute-timeout", "idle-timeout", "renewal-timeout")(
       AuthenticationTimeouts.apply
     )
-
 }
 
 /** Configuration for the authentication feature.
@@ -393,11 +291,6 @@
 )
 
 object AuthenticationConfiguration {
-  private val log = LoggerFactory.getLogger(getClass)
-
-  // The default configuration key under which to lookup the billing configuration.
-  final val parentKey: ConfigKey = ConfigKey("authentication")
-
   given Eq[AuthenticationConfiguration] = Eq.fromUniversalEquals
 
   given ConfigReader[FailedAttempts] =
@@ -426,9 +319,6 @@
 final case class BillingConfiguration(enabled: Boolean)
 
 object BillingConfiguration {
-  // The default configuration key under which to lookup the billing configuration.
-  final val parentKey: ConfigKey = ConfigKey("billing")
-
   given Eq[BillingConfiguration] = Eq.fromUniversalEquals
 
   given ConfigReader[BillingConfiguration] = ConfigReader.forProduct1("enabled")(BillingConfiguration.apply)
@@ -453,9 +343,6 @@
 final case class DarcsConfiguration(executable: Path, repositoriesDirectory: DirectoryPath)
 
 object DarcsConfiguration {
-  // The default configuration key under which to lookup the darcs configuration.
-  final val parentKey: ConfigKey = ConfigKey("darcs")
-
   given Eq[DarcsConfiguration] = Eq.fromUniversalEquals
 
   given ConfigReader[DirectoryPath] = ConfigReader.fromStringOpt(DirectoryPath.fromString)
@@ -472,9 +359,6 @@
 final case class SignupConfiguration(enabled: Boolean)
 
 object SignupConfiguration {
-  // The default configuration key under which to lookup the signup configuration.
-  final val parentKey: ConfigKey = ConfigKey("signup")
-
   given Eq[SignupConfiguration] = Eq.fromUniversalEquals
 
   given ConfigReader[Uri] = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption)
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DatabaseMigrator.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DatabaseMigrator.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DatabaseMigrator.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DatabaseMigrator.scala	2025-01-31 10:46:28.174967313 +0000
@@ -61,6 +61,6 @@
     *   An instance configuration which can be turned into a flyway database migrator by calling the `.load()` method.
     */
   def configureFlyway(url: String, user: String, pass: String): FluentConfiguration =
-    Flyway.configure().defaultSchema("hub").dataSource(url, user, pass)
+    Flyway.configure().defaultSchema("hub").locations("classpath:db/migration/hub").dataSource(url, user, pass)
 
 }
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 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala	2025-01-31 10:46:28.174967313 +0000
@@ -20,18 +20,16 @@
 import java.util.UUID
 
 import cats.effect._
-import cats.syntax.all._
-import de.smederee.hub.VcsMetadataRepositoriesOrdering._
+import de.smederee.email.EmailAddress
+import de.smederee.security._
 import de.smederee.ssh._
 import doobie._
-import doobie.Fragments._
 import doobie.implicits._
 import doobie.postgres.implicits._
 import fs2.Stream
-import org.http4s.Uri
 
 final class DoobieAccountManagementRepository[F[_]: Sync](tx: Transactor[F]) extends AccountManagementRepository[F] {
-  given Meta[Email]           = Meta[String].timap(Email.apply)(_.toString)
+  given Meta[EmailAddress]    = Meta[String].timap(EmailAddress.apply)(_.toString)
   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)
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 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala	2025-01-31 10:46:28.174967313 +0000
@@ -20,13 +20,15 @@
 import java.util.UUID
 
 import cats.effect._
+import de.smederee.email.EmailAddress
+import de.smederee.security._
 import doobie._
 import doobie.Fragments._
 import doobie.implicits._
 import doobie.postgres.implicits._
 
 final class DoobieAuthenticationRepository[F[_]: Sync](tx: Transactor[F]) extends AuthenticationRepository[F] {
-  given Meta[Email]        = Meta[String].timap(Email.apply)(_.toString)
+  given Meta[EmailAddress] = Meta[String].timap(EmailAddress.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)
@@ -50,7 +52,7 @@
     query.query[Account].option.transact(tx)
   }
 
-  override def findAccountByEmail(email: Email): F[Option[Account]] = {
+  override def findAccountByEmail(email: EmailAddress): F[Option[Account]] = {
     val emailFilter = fr"""email = $email"""
     val query       = selectAccountColumns ++ whereAnd(notLockedFilter, emailFilter) ++ fr"""LIMIT 1"""
     query.query[Account].option.transact(tx)
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala	2025-01-31 10:46:28.174967313 +0000
@@ -20,13 +20,15 @@
 import java.util.UUID
 
 import cats.effect._
+import de.smederee.email.EmailAddress
+import de.smederee.security._
 import doobie._
 import doobie.implicits._
 import doobie.postgres.implicits._
 
 final class DoobieSignupRepository[F[_]: Sync](tx: Transactor[F]) extends SignupRepository[F] {
 
-  given Meta[Email]        = Meta[String].timap(Email.apply)(_.toString)
+  given Meta[EmailAddress] = Meta[String].timap(EmailAddress.apply)(_.toString)
   given Meta[PasswordHash] = Meta[String].timap(PasswordHash.apply)(_.toString)
   given Meta[UserId]       = Meta[UUID].timap(UserId.apply)(_.toUUID)
   given Meta[Username]     = Meta[String].timap(Username.apply)(_.toString)
@@ -35,8 +37,8 @@
     sql"""INSERT INTO "hub"."accounts" (uid, name, email, password, created_at, updated_at, validated_email) VALUES(${account.uid}, ${account.name}, ${account.email}, $hash, NOW(), NOW(), ${account.validatedEmail})""".update.run
       .transact(tx)
 
-  override def findEmail(address: Email): F[Option[Email]] =
-    sql"""SELECT email FROM "hub"."accounts" WHERE email = $address""".query[Email].option.transact(tx)
+  override def findEmail(address: EmailAddress): F[Option[EmailAddress]] =
+    sql"""SELECT email FROM "hub"."accounts" WHERE email = $address""".query[EmailAddress].option.transact(tx)
 
   override def findUsername(name: Username): F[Option[Username]] =
     sql"""SELECT name FROM "hub"."accounts" WHERE name = $name""".query[Username].option.transact(tx)
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-01-31 10:46:28.174967313 +0000
@@ -22,6 +22,7 @@
 import cats.effect._
 import cats.syntax.all._
 import de.smederee.hub.VcsMetadataRepositoriesOrdering._
+import de.smederee.security.{ UserId, Username }
 import doobie._
 import doobie.Fragments._
 import doobie.implicits._
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 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-31 10:46:28.174967313 +0000
@@ -33,6 +33,8 @@
 import de.smederee.hub.config._
 import de.smederee.security._
 import de.smederee.ssh._
+import de.smederee.tickets.config._
+import de.smederee.tickets._
 import doobie._
 import org.http4s._
 import org.http4s.dsl.io._
@@ -92,16 +94,25 @@
   def run(args: List[String]): IO[ExitCode] = {
     val databaseMigrator = new DatabaseMigrator[IO]
     for {
-      configuration <- IO(
-        ConfigSource.fromConfig(ConfigFactory.load(getClass.getClassLoader)).loadOrThrow[SmedereeHubConfig]
+      hubConfiguration <- IO(
+        ConfigSource
+          .fromConfig(ConfigFactory.load(getClass.getClassLoader))
+          .at(SmedereeHubConfig.location)
+          .loadOrThrow[SmedereeHubConfig]
+      )
+      ticketsConfiguration <- IO(
+        ConfigSource
+          .fromConfig(ConfigFactory.load(getClass.getClassLoader))
+          .at(SmedereeTicketsConfiguration.location)
+          .loadOrThrow[SmedereeTicketsConfiguration]
       )
       _ <- IO {
         val defaultSecret = "CHANGEME".getBytes(StandardCharsets.UTF_8)
-        if (java.util.Arrays.equals(defaultSecret, configuration.service.authentication.cookieSecret.toArray))
+        if (java.util.Arrays.equals(defaultSecret, hubConfiguration.service.authentication.cookieSecret.toArray))
           log.warn("SERVICE IS USING DEFAULT COOKIE SECRET! PLEASE CONFIGURE A SECURE ONE!")
       }
       repoCheck <- IO {
-        val repositoriesDirectory = configuration.service.darcs.repositoriesDirectory.toPath
+        val repositoriesDirectory = hubConfiguration.service.darcs.repositoriesDirectory.toPath
         if (Files.exists(repositoriesDirectory)) {
           if (Files.isDirectory(repositoriesDirectory)) {
             Right(s"Using repositories directory at: $repositoriesDirectory")
@@ -122,19 +133,19 @@
         case Right(message) => IO(log.info(message))
       }
       _ <- databaseMigrator.migrate(
-        configuration.database.url,
-        configuration.database.user,
-        configuration.database.pass
+        hubConfiguration.database.url,
+        hubConfiguration.database.user,
+        hubConfiguration.database.pass
       )
       transactor = Transactor.fromDriverManager[IO](
-        configuration.database.driver,
-        configuration.database.url,
-        configuration.database.user,
-        configuration.database.pass
+        hubConfiguration.database.driver,
+        hubConfiguration.database.url,
+        hubConfiguration.database.user,
+        hubConfiguration.database.pass
       )
       cryptoClock = java.time.Clock.systemUTC
-      csrfKey <- loadOrCreateCsrfKey(configuration.service.csrfKeyFile)
-      csrfOriginCheck = createCsrfOriginCheck(configuration.service.external)
+      csrfKey <- loadOrCreateCsrfKey(hubConfiguration.service.csrfKeyFile)
+      csrfOriginCheck = createCsrfOriginCheck(hubConfiguration.service.external)
       csrfBuilder     = CSRF[IO, IO](csrfKey, csrfOriginCheck)
       /* The idea behind the `onFailure` part of the CSRF protection middleware is
        * that we simply remove the CSRF cookie and redirect the user to the frontpage.
@@ -143,48 +154,48 @@
        */
       csrfMiddleware = csrfBuilder
         .withClock(cryptoClock)
-        .withCookieDomain(Option(configuration.service.external.host.toString))
+        .withCookieDomain(Option(hubConfiguration.service.external.host.toString))
         .withCookieName(Constants.csrfCookieName.toString)
         .withCookiePath(Option("/"))
         .withCSRFCheck(CSRF.checkCSRFinHeaderAndForm[IO, IO](Constants.csrfCookieName.toString, FunctionK.id))
         .withOnFailure(
           Response[IO](
-            headers = Headers(List(headers.Location(configuration.service.external.createFullUri(uri"/")))),
+            headers = Headers(List(headers.Location(hubConfiguration.service.external.createFullUri(uri"/")))),
             status = Status.SeeOther
           ).removeCookie(Constants.csrfCookieName.toString)
         )
         .build
-      signAndValidate    = SignAndValidate(configuration.service.authentication.cookieSecret)
+      signAndValidate    = SignAndValidate(hubConfiguration.service.authentication.cookieSecret)
       assetsRoutes       = resourceServiceBuilder[IO](Constants.assetsPath.path.toAbsolute.toString).toRoutes
       authenticationRepo = new DoobieAuthenticationRepository[IO](transactor)
       authenticationWithFallThrough = AuthMiddleware.withFallThrough(
         authenticateUserWithFallThrough(
           authenticationRepo,
           signAndValidate,
-          configuration.service.authentication.timeouts
+          hubConfiguration.service.authentication.timeouts
         )
       )
-      darcsWrapper          = new DarcsCommands[IO](configuration.service.darcs.executable)
-      emailMiddleware       = new SimpleJavaMailMiddleware(configuration.service.email)
+      darcsWrapper          = new DarcsCommands[IO](hubConfiguration.service.darcs.executable)
+      emailMiddleware       = new SimpleJavaMailMiddleware(hubConfiguration.service.email)
       accountManagementRepo = new DoobieAccountManagementRepository[IO](transactor)
       accountManagementRoutes = new AccountManagementRoutes[IO](
         accountManagementRepo,
-        configuration.service,
+        hubConfiguration.service,
         emailMiddleware,
         signAndValidate
       )
       authenticationRoutes = new AuthenticationRoutes[IO](
         cryptoClock,
-        configuration.service.authentication,
+        hubConfiguration.service.authentication,
         authenticationRepo,
         signAndValidate
       )
       signUpRepo      = new DoobieSignupRepository[IO](transactor)
-      signUpRoutes    = new SignupRoutes[IO](configuration.service, signUpRepo)
-      landingPages    = new LandingPageRoutes[IO](configuration.service)
+      signUpRoutes    = new SignupRoutes[IO](hubConfiguration.service, signUpRepo)
+      landingPages    = new LandingPageRoutes[IO](hubConfiguration.service)
       vcsMetadataRepo = new DoobieVcsMetadataRepository[IO](transactor)
       vcsRepoRoutes = new VcsRepositoryRoutes[IO](
-        configuration.service,
+        hubConfiguration.service,
         darcsWrapper,
         vcsMetadataRepo
       )
@@ -205,10 +216,16 @@
           landingPages.routes)
       ).orNotFound
       // Create our ssh server fiber (or a dummy one if disabled).
-      sshServerProvider = configuration.service.ssh.enabled match {
+      sshServerProvider = hubConfiguration.service.ssh.enabled match {
         case false => None
         case true =>
-          Option(new SshServerProvider(configuration.service.darcs, configuration.database, configuration.service.ssh))
+          Option(
+            new SshServerProvider(
+              hubConfiguration.service.darcs,
+              hubConfiguration.database,
+              hubConfiguration.service.ssh
+            )
+          )
       }
       sshServer = sshServerProvider.fold(IO.unit.as(ExitCode.Success))(
         _.run().use(server =>
@@ -220,8 +237,8 @@
       // Create our webserver fiber.
       resource = EmberServerBuilder
         .default[IO]
-        .withHost(configuration.service.host)
-        .withPort(configuration.service.port)
+        .withHost(hubConfiguration.service.host)
+        .withPort(hubConfiguration.service.port)
         .withHttpApp(csrfMiddleware.validate()(hubWebService))
         .build
       webServer = resource.use(server =>
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/LoginForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/LoginForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/LoginForm.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/LoginForm.scala	2025-01-31 10:46:28.174967313 +0000
@@ -21,6 +21,7 @@
 import cats.syntax.all._
 import de.smederee.hub.forms._
 import de.smederee.hub.forms.types._
+import de.smederee.security._
 
 /** A data container for the login form.
   *
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala	2025-01-31 10:46:28.174967313 +0000
@@ -115,7 +115,7 @@
       val text = node.asInstanceOf[Text] // We only receive text nodes (see `getNodeTypes`).
       isToDoItem.findFirstMatchIn(text.getLiteral()) match {
         case Some(matchedItem) =>
-          log.info(s"MATCH: ${text.getLiteral()} (${matchedItem.groupCount})")
+          log.debug(s"Matched TODO item: ${text.getLiteral()} (${matchedItem.groupCount})")
           if (matchedItem.group(4) === null) {
             val prefix   = matchedItem.group(1)
             val item     = matchedItem.group(2)
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/RequestHelpers.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/RequestHelpers.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/RequestHelpers.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/RequestHelpers.scala	2025-01-31 10:46:28.174967313 +0000
@@ -18,8 +18,8 @@
 package de.smederee.hub
 
 import cats.syntax.all._
-import de.smederee.hub.config.{ Constants, CsrfToken }
-import de.smederee.security.SignedToken
+import de.smederee.hub.config.Constants
+import de.smederee.security.{ CsrfToken, SignedToken }
 import org.http4s._
 
 trait RequestHelpers[A] {
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/Session.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/Session.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/Session.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Session.scala	2025-01-31 10:46:28.174967313 +0000
@@ -21,6 +21,7 @@
 
 import cats._
 import cats.syntax.all._
+import de.smederee.security.UserId
 
 /** A user session which is used to track logged in users.
   *
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupForm.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupForm.scala	2025-01-31 10:46:28.174967313 +0000
@@ -19,8 +19,10 @@
 
 import cats.data._
 import cats.syntax.all._
+import de.smederee.email.EmailAddress
 import de.smederee.hub.forms._
 import de.smederee.hub.forms.types._
+import de.smederee.security._
 
 /** A data container for our signup form.
   *
@@ -33,17 +35,17 @@
   * @param password
   *   The password of the user.
   */
-final case class SignupForm(name: Username, email: Email, password: Password)
+final case class SignupForm(name: Username, email: EmailAddress, password: Password)
 
 object SignupForm extends FormValidator[SignupForm] {
   val fieldName: FormField     = FormField("name")
   val fieldEmail: FormField    = FormField("email")
   val fieldPassword: FormField = FormField("password")
   override def validate(data: Map[String, String]): ValidatedNec[FormErrors, SignupForm] = {
-    val email: ValidatedNec[FormErrors, Email] = data
+    val email: ValidatedNec[FormErrors, EmailAddress] = data
       .get(fieldEmail)
       .fold(FormFieldError("No email address given!").invalidNec)(s =>
-        Email.from(s).fold(FormFieldError("Invalid email address!").invalidNec)(_.validNec)
+        EmailAddress.from(s).fold(FormFieldError("Invalid email address!").invalidNec)(_.validNec)
       )
       .leftMap(es => NonEmptyChain.of(Map(fieldEmail -> es.toList)))
     val name: ValidatedNec[FormErrors, Username] = data
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala	2025-01-31 10:46:28.174967313 +0000
@@ -17,6 +17,9 @@
 
 package de.smederee.hub
 
+import de.smederee.email.EmailAddress
+import de.smederee.security._
+
 /** A base class for our signup repository which provides the needed database functions.
   *
   * @tparam F
@@ -43,7 +46,7 @@
     * @return
     *   An option which is either empty or contains the email address if it exists in the database.
     */
-  def findEmail(address: Email): F[Option[Email]]
+  def findEmail(address: EmailAddress): F[Option[EmailAddress]]
 
   /** Find the given username in the database accounts table. This function can be used to check if a username has
     * already been taken.
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 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala	2025-01-31 10:46:28.174967313 +0000
@@ -17,8 +17,6 @@
 
 package de.smederee.hub
 
-import java.util.UUID
-
 import cats.data._
 import cats.effect._
 import cats.syntax.all._
@@ -27,15 +25,13 @@
 import de.smederee.hub.config._
 import de.smederee.hub.forms.types.FormErrors
 import de.smederee.hub.forms.types.FormFieldError
-import de.smederee.hub.views
-import org.http4s.FormDataDecoder._
+import de.smederee.security._
 import org.http4s._
 import org.http4s.dsl.Http4sDsl
 import org.http4s.headers.Location
 import org.http4s.implicits._
 import org.http4s.twirl.TwirlInstances._
 import org.slf4j.LoggerFactory
-import play.twirl.api._
 
 /** The routes for handling the user signup process.
   *
@@ -121,7 +117,7 @@
                   )
                   hash     <- Sync[F].delay(signupForm.password.encode)
                   _        <- Sync[F].delay(log.info(s"Going to create account for ${account.name}."))
-                  _        <- repo.createAccount(account, PasswordHash(hash))
+                  _        <- repo.createAccount(account, hash)
                   redirect <- SeeOther(Location(signupUri.addPath("welcome")))
                 } yield redirect
             }
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/types.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/types.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/types.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/types.scala	2025-01-31 10:46:28.174967313 +0000
@@ -17,7 +17,7 @@
 
 package de.smederee.hub
 
-import java.util.{ Base64, UUID }
+import java.util.Base64
 import java.security.SecureRandom
 
 import cats.Eq
@@ -101,56 +101,3 @@
     base64Encoder.encodeToString(buffer)
   }
 }
-
-/** A user id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath.
-  */
-opaque type UserId = UUID
-object UserId {
-  val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r
-
-  given Eq[UserId] = Eq.fromUniversalEquals
-
-  /** Create an instance of UserId from the given UUID type.
-    *
-    * @param source
-    *   An instance of type UUID which will be returned as a UserId.
-    * @return
-    *   The appropriate instance of UserId.
-    */
-  def apply(source: UUID): UserId = source
-
-  /** Try to create an instance of UserId from the given UUID.
-    *
-    * @param source
-    *   A UUID that should fulfil the requirements to be converted into a UserId.
-    * @return
-    *   An option to the successfully converted UserId.
-    */
-  def from(source: UUID): Option[UserId] = Option(source)
-
-  /** Try to create an instance of UserId from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a UserId.
-    * @return
-    *   An option to the successfully converted UserId.
-    */
-  def fromString(source: String): Either[String, UserId] =
-    Option(source)
-      .filter(s => Format.matches(s))
-      .flatMap { uuidString =>
-        Either.catchNonFatal(UUID.fromString(uuidString)).toOption
-      }
-      .toRight("Illegal value for UserId!")
-
-  /** Generate a new random user id.
-    *
-    * @return
-    *   A user id which is pseudo randomly generated.
-    */
-  def randomUserId: UserId = UUID.randomUUID
-
-  extension (uid: UserId) {
-    def toUUID: UUID = uid
-  }
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala	2025-01-31 10:46:28.174967313 +0000
@@ -17,6 +17,7 @@
 
 package de.smederee.hub
 
+import de.smederee.security.Username
 import fs2.Stream
 
 /** A base class for a database repository that should handle all functionality regarding vcs repositories and their
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 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-31 10:46:28.174967313 +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.security.{ CsrfToken, Username }
 import de.smederee.ssh._
 import org.commonmark.parser.Parser
 import org.commonmark.renderer.html.HtmlRenderer
@@ -62,7 +63,7 @@
   private val log = LoggerFactory.getLogger(getClass)
 
   private val createRepoPath  = uri"/repo/create"
-  private val MaximumFileSize = 131072L // TODO Move to configuration directive.
+  private val MaximumFileSize = 131072L // TODO: Move to configuration directive.
 
   val darcsConfig = configuration.darcs
   val linkConfig  = configuration.external
@@ -100,7 +101,7 @@
             case _                          => None
           }
       }
-      // TODO Replace with whatever we implement as proper permission model. ;-)
+      // TODO: Replace with whatever we implement as proper permission model. ;-)
       repoAndId = currentUser match {
         case None => loadedRepo.filter(tuple => tuple._1.isPrivate === false)
         case Some(user) =>
@@ -266,7 +267,7 @@
         linkConfig.createFullUri(Uri(path = Uri.Path.unsafeFromString(s"~$repositoriesOwnerName")))
       )
       resp <- owner match {
-        case None => // TODO Better error message...
+        case None => // TODO: Better error message...
           NotFound(
             views.html.showRepositories()(
               actionBaseUri,
@@ -330,9 +331,17 @@
           )
         )
       )
-      vcsLog     <- darcs.log(directory.toNIO)(repositoryName.toString)(Chain(s"--max-count=2", "--xml-output"))
-      xmlLog     <- Sync[F].delay(scala.xml.XML.loadString(vcsLog.stdout.toList.mkString))
-      patches    <- Sync[F].delay((xmlLog \ "patch").flatMap(VcsRepositoryPatchMetadata.fromDarcsXmlLog).toList)
+      vcsLog <- darcs.log(directory.toNIO)(repositoryName.toString)(Chain(s"--max-count=2", "--xml-output"))
+      xmlLog <- Sync[F].delay(scala.xml.XML.loadString(vcsLog.stdout.toList.mkString))
+      patches <- Sync[F].delay(
+        (xmlLog \ "patch").flatMap(VcsRepositoryPatchMetadata.fromDarcsXmlLog).toList.map { patch =>
+          // TODO: Move the hard coded number into a configuration value.
+          if (patch.comment.exists(_.length > 320))
+            patch.copy(comment = patch.comment.map(c => VcsPatchComment(c.toString.take(320) + "...")))
+          else
+            patch
+        }
+      )
       readmeData <- repo.traverse(repo => doLoadReadme(repo))
       readme <- readmeData match {
         case Some((lines, Some(filename))) =>
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala	2025-01-31 10:46:28.174967313 +0000
@@ -23,6 +23,7 @@
 import cats._
 import cats.data._
 import cats.syntax.all._
+import de.smederee.security.{ UserId, Username }
 import org.http4s.Uri
 import org.slf4j.LoggerFactory
 
@@ -202,6 +203,9 @@
     */
   def from(source: String): Option[VcsPatchComment] = Option(source).filter(_.nonEmpty)
 
+  extension (comment: VcsPatchComment) {
+    def length: Int = comment.length
+  }
 }
 
 opaque type VcsPatchFilename = String
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/DarcsSshCommand.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/DarcsSshCommand.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/DarcsSshCommand.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/DarcsSshCommand.scala	2025-01-31 10:46:28.174967313 +0000
@@ -26,7 +26,8 @@
 import cats.effect.unsafe.implicits.global
 import cats.syntax.all._
 import de.smederee.hub.config._
-import de.smederee.hub.{ UserId, Username, VcsRepositoryName }
+import de.smederee.hub.VcsRepositoryName
+import de.smederee.security.{ UserId, Username }
 import org.apache.sshd.scp.common.ScpHelper
 import org.apache.sshd.scp.server._
 import org.apache.sshd.server.channel.ChannelSession
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala	2025-01-31 10:46:28.178967320 +0000
@@ -21,6 +21,7 @@
 
 import cats.effect._
 import de.smederee.hub._
+import de.smederee.security.{ UserId, Username }
 import doobie._
 import doobie.Fragments._
 import doobie.implicits._
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala	2025-01-31 10:46:28.178967320 +0000
@@ -23,7 +23,7 @@
 
 import cats._
 import cats.syntax.all._
-import de.smederee.hub._
+import de.smederee.security.UserId
 import org.apache.sshd.common.config.keys.AuthorizedKeyEntry
 import org.bouncycastle.crypto.util.OpenSSHPublicKeyUtil
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticationRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticationRepository.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticationRepository.scala	2025-01-31 10:46:28.178967320 +0000
@@ -20,6 +20,7 @@
 import java.util.UUID
 
 import de.smederee.hub._
+import de.smederee.security.Username
 
 /** The base class for needed repository functionality releated to ssh authentication like loading/providing keys.
   *
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala	2025-01-31 10:46:28.158967286 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala	2025-01-31 10:46:28.178967320 +0000
@@ -26,8 +26,8 @@
 import cats.effect.std.Dispatcher
 import cats.syntax.all._
 import com.comcast.ip4s._
-import de.smederee.hub.{ UserId, Username }
 import de.smederee.hub.config._
+import de.smederee.security.{ UserId, Username }
 import doobie._
 import org.apache.sshd.common.AttributeRepository.AttributeKey
 import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory
@@ -99,9 +99,6 @@
 )
 
 object SshServerConfiguration {
-  // The default configuration key under which to lookup the ssh server configuration.
-  final val parentKey: ConfigKey = ConfigKey("ssh")
-
   // A key marker for storing the owner id of an ssh-key in the ssh server session.
   final val SshKeyOwnerIdAttribute: AttributeKey[UserId] = new AttributeKey[UserId]()
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala	2025-01-31 10:46:28.178967320 +0000
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import cats._
+import cats.data._
+import cats.syntax.all._
+import de.smederee.tickets.forms.FormValidator
+import de.smederee.tickets.forms.types._
+
+/** Data container to edit a label.
+  *
+  * @param id
+  *   An optional attribute containing the unique internal database ID for the label.
+  * @param name
+  *   A short descriptive name for the label which is supposed to be unique in a project context.
+  * @param description
+  *   An optional description if needed.
+  * @param colour
+  *   A hexadecimal HTML colour code which can be used to mark the label on a rendered website.
+  */
+final case class LabelForm(
+    id: Option[LabelId],
+    name: LabelName,
+    description: Option[LabelDescription],
+    colour: ColourCode
+)
+
+object LabelForm extends FormValidator[LabelForm] {
+  val fieldColour: FormField      = FormField("colour")
+  val fieldDescription: FormField = FormField("description")
+  val fieldId: FormField          = FormField("id")
+  val fieldName: FormField        = FormField("name")
+
+  /** Create a form for editing a label from the given label data.
+    *
+    * @param label
+    *   The label which provides the data for the edit form.
+    * @return
+    *   A label form filled with the data from the given label.
+    */
+  def fromLabel(label: Label): LabelForm =
+    LabelForm(id = label.id, name = label.name, description = label.description, colour = label.colour)
+
+  override def validate(data: Map[String, String]): ValidatedNec[FormErrors, LabelForm] = {
+    val id = data
+      .get(fieldId)
+      .fold(Option.empty[LabelId].validNec)(s =>
+        LabelId.fromString(s).fold(FormFieldError("Invalid label id!").invalidNec)(id => Option(id).validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldId -> es.toList)))
+    val name = data
+      .get(fieldName)
+      .map(_.trim) // We strip leading and trailing whitespace!
+      .fold(FormFieldError("No label name given!").invalidNec)(s =>
+        LabelName.from(s).fold(FormFieldError("Invalid label name!").invalidNec)(_.validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldName -> es.toList)))
+    val description = data
+      .get(fieldDescription)
+      .fold(Option.empty[LabelDescription].validNec) { s =>
+        if (s.trim.isEmpty)
+          Option.empty[LabelDescription].validNec // Sometimes "empty" strings are sent.
+        else
+          LabelDescription
+            .from(s)
+            .fold(FormFieldError("Invalid label description!").invalidNec)(descr => Option(descr).validNec)
+      }
+      .leftMap(es => NonEmptyChain.of(Map(fieldDescription -> es.toList)))
+    val colour = data
+      .get(fieldColour)
+      .fold(FormFieldError("No label colour given!").invalidNec)(s =>
+        ColourCode.from(s).fold(FormFieldError("Invalid label colour!").invalidNec)(_.validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldColour -> es.toList)))
+    (id, name, description, colour).mapN { case (id, name, description, colour) =>
+      LabelForm(id, name, description, colour)
+    }
+  }
+
+  extension (form: LabelForm) {
+
+    /** Convert the form class into a stringified map which is used as underlying data type for form handling in the
+      * twirl templating library.
+      *
+      * @return
+      *   A stringified map containing the data of the form.
+      */
+    def toMap: Map[String, String] =
+      Map(
+        LabelForm.fieldId.toString          -> form.id.map(_.toString).getOrElse(""),
+        LabelForm.fieldName.toString        -> form.name.toString,
+        LabelForm.fieldDescription.toString -> form.description.map(_.toString).getOrElse(""),
+        LabelForm.fieldColour.toString      -> form.colour.toString
+      )
+  }
+
+}
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala	2025-01-31 10:46:28.178967320 +0000
@@ -0,0 +1,453 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import cats._
+import cats.data._
+import cats.effect._
+import cats.syntax.all._
+import de.smederee.html.LinkTools._
+import de.smederee.html._
+import de.smederee.hub.RequestHelpers.instances.given
+import de.smederee.hub.Account
+import de.smederee.security.{ CsrfToken, UserId, Username }
+import de.smederee.tickets.Project
+import de.smederee.tickets.config._
+import de.smederee.tickets.forms.types._
+import org.http4s._
+import org.http4s.dsl.Http4sDsl
+import org.http4s.dsl.impl._
+import org.http4s.headers.Location
+import org.http4s.implicits._
+import org.http4s.twirl.TwirlInstances._
+import org.slf4j.LoggerFactory
+
+/** Routes for managing labels (basically CRUD functionality).
+  *
+  * @param configuration
+  *   The ticket service configuration.
+  * @param labelRepo
+  *   A repository for handling database operations for labels.
+  * @param projectRepo
+  *   A repository for handling database operations regarding our vcs repositories and their metadata.
+  * @tparam F
+  *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
+  */
+final class LabelRoutes[F[_]: Async](
+    configuration: SmedereeTicketsConfiguration,
+    labelRepo: LabelRepository[F],
+    projectRepo: ProjectRepository[F]
+) extends Http4sDsl[F] {
+  private val log = LoggerFactory.getLogger(getClass)
+
+  given CsrfProtectionConfiguration = configuration.csrfProtection
+
+  val linkConfig = configuration.externalUrl
+
+  /** Logic for rendering a list of all labels for a project and optionally management functionality.
+    *
+    * @param csrf
+    *   An optional CSRF-Token that shall be used.
+    * @param user
+    *   An optional user account for whom the list of labels shall be rendered.
+    * @param projectOwnerName
+    *   The username of the account who owns the project.
+    * @param projectName
+    *   The name of the project.
+    * @return
+    *   An HTTP response containing the rendered HTML.
+    */
+  private def doShowLabels(
+      csrf: Option[CsrfToken]
+  )(user: Option[Account])(projectOwnerName: Username)(projectName: ProjectName): F[Response[F]] =
+    for {
+      projectAndId <- loadProject(user)(projectOwnerName, projectName)
+      resp <- projectAndId match {
+        case Some((repo, repoId)) =>
+          for {
+            labels <- labelRepo.allLabels(repoId).compile.toList
+            projectBaseUri <- Sync[F].delay(
+              linkConfig.createFullUri(
+                Uri(path =
+                  Uri.Path(
+                    Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString))
+                  )
+                )
+              )
+            )
+            resp <- Ok(
+              views.html.editLabels()(
+                projectBaseUri.addSegment("labels"),
+                csrf,
+                labels,
+                projectBaseUri,
+                "Manage your project labels.".some,
+                user,
+                repo
+              )()
+            )
+          } yield resp
+        case _ => NotFound()
+      }
+    } yield resp
+
+  /** Load the project metadata with the given owner and name from the database and return it and its primary key id if
+    * the project exists and is readable by the given user account.
+    *
+    * @param currentUser
+    *   The user account that is requesting access to the project or None for a guest user.
+    * @param projectOwnerName
+    *   The name of the account that owns the project.
+    * @param projectName
+    *   The name of the project. A project 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.
+    * @return
+    *   An option to a tuple holding the [[Project]] and its primary key id.
+    */
+  private def loadProject(
+      currentUser: Option[Account]
+  )(projectOwnerName: ProjectOwnerName, projectName: ProjectName): F[Option[(Project, Long)]] =
+    for {
+      owner <- projectRepo.findProjectOwner(projectOwnerName)
+      loadedRepo <- owner match {
+        case None => Sync[F].pure(None)
+        case Some(owner) =>
+          (
+            projectRepo.findProject(owner, projectName),
+            projectRepo.findProjectId(owner, projectName)
+          ).mapN {
+            case (Some(repo), Some(repoId)) => (repo, repoId).some
+            case _                          => None
+          }
+      }
+      // TODO Replace with whatever we implement as proper permission model. ;-)
+      projectAndId = currentUser match {
+        case None => loadedRepo.filter(tuple => tuple._1.isPrivate === false)
+        case Some(user) =>
+          loadedRepo.filter(tuple => tuple._1.isPrivate === false || tuple._1.owner === user.toProjectOwner)
+      }
+    } yield projectAndId
+
+  private val addLabel: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
+          projectName
+        ) / "labels" as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+          projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
+          resp <- projectAndId match {
+            case Some(repo, repoId) =>
+              for {
+                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add labels!"))
+                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)!
+                  }
+                }
+                form   <- Sync[F].delay(LabelForm.validate(formData))
+                labels <- projectAndId.traverse(tuple => labelRepo.allLabels(tuple._2).compile.toList)
+                projectBaseUri <- Sync[F].delay(
+                  linkConfig.createFullUri(
+                    Uri(path =
+                      Uri.Path(
+                        Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString))
+                      )
+                    )
+                  )
+                )
+                resp <- form match {
+                  case Validated.Invalid(errors) =>
+                    BadRequest(
+                      views.html.editLabels()(
+                        projectBaseUri.addSegment("labels"),
+                        csrf,
+                        labels.getOrElse(List.empty),
+                        projectBaseUri,
+                        "Manage your project labels.".some,
+                        user.some,
+                        repo
+                      )(formData, FormErrors.fromNec(errors))
+                    )
+                  case Validated.Valid(labelData) =>
+                    val label = Label(None, labelData.name, labelData.description, labelData.colour)
+                    for {
+                      checkDuplicate <- labelRepo.findLabel(repoId)(labelData.name)
+                      resp <- checkDuplicate match {
+                        case None =>
+                          labelRepo.createLabel(repoId)(label) *> SeeOther(
+                            Location(projectBaseUri.addSegment("labels"))
+                          )
+                        case Some(_) =>
+                          BadRequest(
+                            views.html.editLabels()(
+                              projectBaseUri.addSegment("labels"),
+                              csrf,
+                              labels.getOrElse(List.empty),
+                              projectBaseUri,
+                              "Manage your project labels.".some,
+                              user.some,
+                              repo
+                            )(
+                              formData,
+                              Map(
+                                LabelForm.fieldName -> List(FormFieldError("A label with that name already exists!"))
+                              )
+                            )
+                          )
+                      }
+                    } yield resp
+                }
+              } yield resp
+            case _ => NotFound()
+          }
+        } yield resp
+      }
+  }
+
+  private val deleteLabel: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
+          projectName
+        ) / "label" / LabelNamePathParameter(labelName) / "delete" as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+          projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
+          resp <- projectAndId match {
+            case Some(repo, repoId) =>
+              for {
+                _     <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add labels!"))
+                label <- labelRepo.findLabel(repoId)(labelName)
+                resp <- label match {
+                  case Some(label) =>
+                    for {
+                      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)!
+                        }
+                      }
+                      projectBaseUri <- Sync[F].delay(
+                        linkConfig.createFullUri(
+                          Uri(path =
+                            Uri.Path(
+                              Vector(
+                                Uri.Path.Segment(s"~$projectOwnerName"),
+                                Uri.Path.Segment(projectName.toString)
+                              )
+                            )
+                          )
+                        )
+                      )
+                      userIsSure <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes"))
+                      labelIdMatches <- Sync[F].delay(
+                        formData
+                          .get(LabelForm.fieldId)
+                          .flatMap(LabelId.fromString)
+                          .exists(id => label.id.exists(_ === id))
+                      )
+                      labelNameMatches <- Sync[F].delay(
+                        formData.get(LabelForm.fieldName).flatMap(LabelName.from).exists(_ === labelName)
+                      )
+                      resp <- (labelIdMatches && labelNameMatches && userIsSure) match {
+                        case false => BadRequest("Invalid form data!")
+                        case true =>
+                          labelRepo.deleteLabel(label) *> SeeOther(
+                            Location(projectBaseUri.addSegment("labels"))
+                          )
+                      }
+                    } yield resp
+                  case _ => NotFound("Label not found!")
+                }
+              } yield resp
+            case _ => NotFound("Repository not found!")
+          }
+        } yield resp
+      }
+  }
+
+  private val editLabel: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
+          projectName
+        ) / "label" / LabelNamePathParameter(labelName) as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+          projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
+          label <- projectAndId match {
+            case Some((_, repoId)) => labelRepo.findLabel(repoId)(labelName)
+            case _                 => Sync[F].delay(None)
+          }
+          resp <- (projectAndId, label) match {
+            case (Some(repo, repoId), Some(label)) =>
+              for {
+                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add labels!"))
+                projectBaseUri <- Sync[F].delay(
+                  linkConfig.createFullUri(
+                    Uri(path =
+                      Uri.Path(
+                        Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString))
+                      )
+                    )
+                  )
+                )
+                actionUri <- Sync[F].delay(projectBaseUri.addSegment("label").addSegment(label.name.toString))
+                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)!
+                  }
+                }
+                labelIdMatches <- Sync[F].delay(
+                  formData
+                    .get(LabelForm.fieldId)
+                    .flatMap(LabelId.fromString)
+                    .exists(id => label.id.exists(_ === id)) match {
+                    case false =>
+                      NonEmptyChain
+                        .of(Map(LabelForm.fieldGlobal -> List(FormFieldError("Label ID does not match!"))))
+                        .invalidNec
+                    case true => label.id.validNec
+                  }
+                )
+                form <- Sync[F].delay(LabelForm.validate(formData))
+                resp <- form match {
+                  case Validated.Invalid(errors) =>
+                    BadRequest(
+                      views.html.editLabel()(
+                        actionUri,
+                        csrf,
+                        label,
+                        projectBaseUri,
+                        s"Edit label ${label.name}".some,
+                        user,
+                        repo
+                      )(
+                        formData.toMap,
+                        FormErrors.fromNec(errors)
+                      )
+                    )
+                  case Validated.Valid(labelData) =>
+                    val updatedLabel =
+                      label.copy(name = labelData.name, description = labelData.description, colour = labelData.colour)
+                    for {
+                      checkDuplicate <- labelRepo.findLabel(repoId)(updatedLabel.name)
+                      resp <- checkDuplicate.filterNot(_.id === updatedLabel.id) match {
+                        case None =>
+                          labelRepo.updateLabel(updatedLabel) *> SeeOther(
+                            Location(projectBaseUri.addSegment("labels"))
+                          )
+                        case Some(_) =>
+                          BadRequest(
+                            views.html.editLabel()(
+                              actionUri,
+                              csrf,
+                              label,
+                              projectBaseUri,
+                              s"Edit label ${label.name}".some,
+                              user,
+                              repo
+                            )(
+                              formData,
+                              Map(
+                                LabelForm.fieldName -> List(FormFieldError("A label with that name already exists!"))
+                              )
+                            )
+                          )
+                      }
+                    } yield resp
+                }
+              } yield resp
+            case _ => NotFound()
+          }
+        } yield resp
+      }
+  }
+
+  private val showEditLabelForm: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
+          projectName
+        ) / "label" / LabelNamePathParameter(labelName) / "edit" as user =>
+      for {
+        csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+        projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
+        label <- projectAndId match {
+          case Some((_, repoId)) => labelRepo.findLabel(repoId)(labelName)
+          case _                 => Sync[F].delay(None)
+        }
+        resp <- (projectAndId, label) match {
+          case (Some(repo, repoId), Some(label)) =>
+            for {
+              projectBaseUri <- Sync[F].delay(
+                linkConfig.createFullUri(
+                  Uri(path =
+                    Uri.Path(
+                      Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString))
+                    )
+                  )
+                )
+              )
+              actionUri <- Sync[F].delay(projectBaseUri.addSegment("label").addSegment(label.name.toString))
+              formData  <- Sync[F].delay(LabelForm.fromLabel(label))
+              resp <- Ok(
+                views.html
+                  .editLabel()(actionUri, csrf, label, projectBaseUri, s"Edit label ${label.name}".some, user, repo)(
+                    formData.toMap
+                  )
+              )
+            } yield resp
+          case _ => NotFound()
+        }
+      } yield resp
+  }
+
+  private val showEditLabelsPage: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
+          projectName
+        ) / "labels" as user =>
+      for {
+        csrf <- Sync[F].delay(ar.req.getCsrfToken)
+        resp <- doShowLabels(csrf)(user.some)(projectOwnerName)(projectName)
+      } yield resp
+  }
+
+  private val showLabelsForGuests: HttpRoutes[F] = HttpRoutes.of {
+    case req @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
+          projectName
+        ) / "labels" =>
+      for {
+        csrf <- Sync[F].delay(req.getCsrfToken)
+        resp <- doShowLabels(csrf)(None)(projectOwnerName)(projectName)
+      } yield resp
+  }
+
+  val protectedRoutes = addLabel <+> deleteLabel <+> editLabel <+> showEditLabelForm <+> showEditLabelsPage
+
+  val routes = showLabelsForGuests
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala	2025-01-31 10:46:28.178967320 +0000
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import java.time._
+
+import cats._
+import cats.data._
+import cats.syntax.all._
+import de.smederee.tickets.forms.FormValidator
+import de.smederee.tickets.forms.types._
+
+/** Data container to edit a milestone.
+  *
+  * @param id
+  *   An optional attribute containing the unique internal database ID for the milestone.
+  * @param title
+  *   A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within
+  *   a project context.
+  * @param description
+  *   An optional longer description of the milestone.
+  * @param dueDate
+  *   An optional date on which the milestone is supposed to be reached.
+  */
+final case class MilestoneForm(
+    id: Option[MilestoneId],
+    title: MilestoneTitle,
+    description: Option[MilestoneDescription],
+    dueDate: Option[LocalDate]
+)
+
+object MilestoneForm extends FormValidator[MilestoneForm] {
+  val fieldDescription: FormField = FormField("description")
+  val fieldDueDate: FormField     = FormField("due_date")
+  val fieldId: FormField          = FormField("id")
+  val fieldTitle: FormField       = FormField("title")
+
+  /** Create a form for editing a milestone from the given milestone data.
+    *
+    * @param milestone
+    *   The milestone which provides the data for the edit form.
+    * @return
+    *   A milestone form filled with the data from the given milestone.
+    */
+  def fromMilestone(milestone: Milestone): MilestoneForm =
+    MilestoneForm(
+      id = milestone.id,
+      title = milestone.title,
+      description = milestone.description,
+      dueDate = milestone.dueDate
+    )
+
+  override def validate(data: Map[String, String]): ValidatedNec[FormErrors, MilestoneForm] = {
+    val id = data
+      .get(fieldId)
+      .fold(Option.empty[MilestoneId].validNec)(s =>
+        MilestoneId.fromString(s).fold(FormFieldError("Invalid milestone id!").invalidNec)(id => Option(id).validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldId -> es.toList)))
+    val title = data
+      .get(fieldTitle)
+      .map(_.trim) // We strip leading and trailing whitespace!
+      .fold(FormFieldError("No milestone title given!").invalidNec)(s =>
+        MilestoneTitle.from(s).fold(FormFieldError("Invalid milestone title!").invalidNec)(_.validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldTitle -> es.toList)))
+    val description = data
+      .get(fieldDescription)
+      .fold(Option.empty[MilestoneDescription].validNec) { s =>
+        if (s.trim.isEmpty)
+          Option.empty[MilestoneDescription].validNec // Sometimes "empty" strings are sent.
+        else
+          MilestoneDescription
+            .from(s)
+            .fold(FormFieldError("Invalid milestone description!").invalidNec)(descr => Option(descr).validNec)
+      }
+      .leftMap(es => NonEmptyChain.of(Map(fieldDescription -> es.toList)))
+    val dueDate = data
+      .get(fieldDueDate)
+      .fold(Option.empty[LocalDate].validNec) { s =>
+        if (s.trim.isEmpty)
+          Option.empty[LocalDate].validNec
+        else
+          Validated
+            .catchNonFatal(LocalDate.parse(s))
+            .map(date => Option(date))
+      }
+      .leftMap(_ => NonEmptyChain.of(Map(fieldDueDate -> List(FormFieldError("Invalid milestone due date!")))))
+    (id, title, description, dueDate).mapN { case (id, title, description, dueDate) =>
+      MilestoneForm(id, title, description, dueDate)
+    }
+  }
+
+  extension (form: MilestoneForm) {
+
+    /** Convert the form class into a stringified map which is used as underlying data type for form handling in the
+      * twirl templating library.
+      *
+      * @return
+      *   A stringified map containing the data of the form.
+      */
+    def toMap: Map[String, String] =
+      Map(
+        MilestoneForm.fieldId.toString          -> form.id.map(_.toString).getOrElse(""),
+        MilestoneForm.fieldTitle.toString       -> form.title.toString,
+        MilestoneForm.fieldDescription.toString -> form.description.map(_.toString).getOrElse(""),
+        MilestoneForm.fieldDueDate.toString     -> form.dueDate.map(_.toString).getOrElse("")
+      )
+  }
+
+}
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2025-01-31 10:46:28.178967320 +0000
@@ -0,0 +1,472 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import cats._
+import cats.data._
+import cats.effect._
+import cats.syntax.all._
+import de.smederee.html.LinkTools._
+import de.smederee.html._
+import de.smederee.hub.Account
+import de.smederee.hub.RequestHelpers.instances.given
+import de.smederee.security.{ CsrfToken, UserId, Username }
+import de.smederee.tickets.Project
+import de.smederee.tickets.config._
+import de.smederee.tickets.forms.types._
+import org.http4s._
+import org.http4s.dsl.Http4sDsl
+import org.http4s.dsl.impl._
+import org.http4s.headers.Location
+import org.http4s.implicits._
+import org.http4s.twirl.TwirlInstances._
+import org.slf4j.LoggerFactory
+
+/** Routes for managing milestones (basically CRUD functionality).
+  *
+  * @param configuration
+  *   The ticket service configuration.
+  * @param milestoneRepo
+  *   A repository for handling database operations for milestones.
+  * @param projectRepo
+  *   A repository for handling database operations regarding our vcs repositories and their metadata.
+  * @tparam F
+  *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
+  */
+final class MilestoneRoutes[F[_]: Async](
+    configuration: SmedereeTicketsConfiguration,
+    milestoneRepo: MilestoneRepository[F],
+    projectRepo: ProjectRepository[F]
+) extends Http4sDsl[F] {
+  private val log = LoggerFactory.getLogger(getClass)
+
+  given CsrfProtectionConfiguration = configuration.csrfProtection
+
+  val linkConfig = configuration.externalUrl
+
+  /** Logic for rendering a list of all milestones for a repository and optionally management functionality.
+    *
+    * @param csrf
+    *   An optional CSRF-Token that shall be used.
+    * @param user
+    *   An optional user account for whom the list of milestones shall be rendered.
+    * @param repositoryOwnerName
+    *   The username of the account who owns the repository.
+    * @param repositoryName
+    *   The name of the repository.
+    * @return
+    *   An HTTP response containing the rendered HTML.
+    */
+  private def doShowMilestones(
+      csrf: Option[CsrfToken]
+  )(user: Option[Account])(repositoryOwnerName: Username)(repositoryName: ProjectName): F[Response[F]] =
+    for {
+      repoAndId <- loadRepo(user)(repositoryOwnerName, repositoryName)
+      resp <- repoAndId match {
+        case Some((repo, repoId)) =>
+          for {
+            milestones <- milestoneRepo.allMilestones(repoId).compile.toList
+            repositoryBaseUri <- Sync[F].delay(
+              linkConfig.createFullUri(
+                Uri(path =
+                  Uri.Path(
+                    Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
+                  )
+                )
+              )
+            )
+            resp <- Ok(
+              views.html.editMilestones()(
+                repositoryBaseUri.addSegment("milestones"),
+                csrf,
+                milestones,
+                repositoryBaseUri,
+                "Manage your repository milestones.".some,
+                user,
+                repo
+              )()
+            )
+          } yield resp
+        case _ => NotFound()
+      }
+    } yield resp
+
+  /** Load the repository metadata with the given owner and name from the database and return it and its primary key id
+    * if the repository exists and is readable by the given user account.
+    *
+    * @param currentUser
+    *   The user account that is requesting access to the repository or None for a guest user.
+    * @param repositoryOwnerName
+    *   The name of the account that owns the repository.
+    * @param repositoryName
+    *   The name of the repository. 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.
+    * @return
+    *   An option to a tuple holding the [[Project]] and its primary key id.
+    */
+  private def loadRepo(
+      currentUser: Option[Account]
+  )(repositoryOwnerName: Username, repositoryName: ProjectName): F[Option[(Project, Long)]] =
+    for {
+      owner <- projectRepo.findProjectOwner(repositoryOwnerName)
+      loadedRepo <- owner match {
+        case None => Sync[F].pure(None)
+        case Some(owner) =>
+          (
+            projectRepo.findProject(owner, repositoryName),
+            projectRepo.findProjectId(owner, repositoryName)
+          ).mapN {
+            case (Some(repo), Some(repoId)) => (repo, repoId).some
+            case _                          => None
+          }
+      }
+      // TODO Replace with whatever we implement as proper permission model. ;-)
+      repoAndId = currentUser match {
+        case None => loadedRepo.filter(tuple => tuple._1.isPrivate === false)
+        case Some(user) =>
+          loadedRepo.filter(tuple => tuple._1.isPrivate === false || tuple._1.owner === user.toProjectOwner)
+      }
+    } yield repoAndId
+
+  private val addMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter(
+          repositoryName
+        ) / "milestones" as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf      <- Sync[F].delay(ar.req.getCsrfToken)
+          repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
+          resp <- repoAndId match {
+            case Some(repo, repoId) =>
+              for {
+                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add milestones!"))
+                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)!
+                  }
+                }
+                form       <- Sync[F].delay(MilestoneForm.validate(formData))
+                milestones <- repoAndId.traverse(tuple => milestoneRepo.allMilestones(tuple._2).compile.toList)
+                repositoryBaseUri <- Sync[F].delay(
+                  linkConfig.createFullUri(
+                    Uri(path =
+                      Uri.Path(
+                        Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
+                      )
+                    )
+                  )
+                )
+                resp <- form match {
+                  case Validated.Invalid(errors) =>
+                    BadRequest(
+                      views.html.editMilestones()(
+                        repositoryBaseUri.addSegment("milestones"),
+                        csrf,
+                        milestones.getOrElse(List.empty),
+                        repositoryBaseUri,
+                        "Manage your repository milestones.".some,
+                        user.some,
+                        repo
+                      )(formData, FormErrors.fromNec(errors))
+                    )
+                  case Validated.Valid(milestoneData) =>
+                    val milestone =
+                      Milestone(None, milestoneData.title, milestoneData.description, milestoneData.dueDate)
+                    for {
+                      checkDuplicate <- milestoneRepo.findMilestone(repoId)(milestoneData.title)
+                      resp <- checkDuplicate match {
+                        case None =>
+                          milestoneRepo.createMilestone(repoId)(milestone) *> SeeOther(
+                            Location(repositoryBaseUri.addSegment("milestones"))
+                          )
+                        case Some(_) =>
+                          BadRequest(
+                            views.html.editMilestones()(
+                              repositoryBaseUri.addSegment("milestones"),
+                              csrf,
+                              milestones.getOrElse(List.empty),
+                              repositoryBaseUri,
+                              "Manage your repository milestones.".some,
+                              user.some,
+                              repo
+                            )(
+                              formData,
+                              Map(
+                                MilestoneForm.fieldTitle -> List(
+                                  FormFieldError("A milestone with that name already exists!")
+                                )
+                              )
+                            )
+                          )
+                      }
+                    } yield resp
+                }
+              } yield resp
+            case _ => NotFound()
+          }
+        } yield resp
+      }
+  }
+
+  private val deleteMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter(
+          repositoryName
+        ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) / "delete" as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf      <- Sync[F].delay(ar.req.getCsrfToken)
+          repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
+          resp <- repoAndId match {
+            case Some(repo, repoId) =>
+              for {
+                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add milestones!"))
+                milestone <- milestoneRepo.findMilestone(repoId)(milestoneTitle)
+                resp <- milestone match {
+                  case Some(milestone) =>
+                    for {
+                      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)!
+                        }
+                      }
+                      repositoryBaseUri <- Sync[F].delay(
+                        linkConfig.createFullUri(
+                          Uri(path =
+                            Uri.Path(
+                              Vector(
+                                Uri.Path.Segment(s"~$repositoryOwnerName"),
+                                Uri.Path.Segment(repositoryName.toString)
+                              )
+                            )
+                          )
+                        )
+                      )
+                      userIsSure <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes"))
+                      milestoneIdMatches <- Sync[F].delay(
+                        formData
+                          .get(MilestoneForm.fieldId)
+                          .flatMap(MilestoneId.fromString)
+                          .exists(id => milestone.id.exists(_ === id))
+                      )
+                      milestoneTitleMatches <- Sync[F].delay(
+                        formData.get(MilestoneForm.fieldTitle).flatMap(MilestoneTitle.from).exists(_ === milestoneTitle)
+                      )
+                      resp <- (milestoneIdMatches && milestoneTitleMatches && userIsSure) match {
+                        case false => BadRequest("Invalid form data!")
+                        case true =>
+                          milestoneRepo.deleteMilestone(milestone) *> SeeOther(
+                            Location(repositoryBaseUri.addSegment("milestones"))
+                          )
+                      }
+                    } yield resp
+                  case _ => NotFound("Milestone not found!")
+                }
+              } yield resp
+            case _ => NotFound("Repository not found!")
+          }
+        } yield resp
+      }
+  }
+
+  private val editMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter(
+          repositoryName
+        ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf      <- Sync[F].delay(ar.req.getCsrfToken)
+          repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
+          milestone <- repoAndId match {
+            case Some((_, repoId)) => milestoneRepo.findMilestone(repoId)(milestoneTitle)
+            case _                 => Sync[F].delay(None)
+          }
+          resp <- (repoAndId, milestone) match {
+            case (Some(repo, repoId), Some(milestone)) =>
+              for {
+                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add milestones!"))
+                repositoryBaseUri <- Sync[F].delay(
+                  linkConfig.createFullUri(
+                    Uri(path =
+                      Uri.Path(
+                        Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
+                      )
+                    )
+                  )
+                )
+                actionUri <- Sync[F].delay(
+                  repositoryBaseUri.addSegment("milestone").addSegment(milestone.title.toString)
+                )
+                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)!
+                  }
+                }
+                milestoneIdMatches <- Sync[F].delay(
+                  formData
+                    .get(MilestoneForm.fieldId)
+                    .flatMap(MilestoneId.fromString)
+                    .exists(id => milestone.id.exists(_ === id)) match {
+                    case false =>
+                      NonEmptyChain
+                        .of(Map(MilestoneForm.fieldGlobal -> List(FormFieldError("Milestone ID does not match!"))))
+                        .invalidNec
+                    case true => milestone.id.validNec
+                  }
+                )
+                form <- Sync[F].delay(MilestoneForm.validate(formData))
+                resp <- form match {
+                  case Validated.Invalid(errors) =>
+                    BadRequest(
+                      views.html.editMilestone()(
+                        actionUri,
+                        csrf,
+                        milestone,
+                        repositoryBaseUri,
+                        s"Edit milestone ${milestone.title}".some,
+                        user,
+                        repo
+                      )(
+                        formData.toMap,
+                        FormErrors.fromNec(errors)
+                      )
+                    )
+                  case Validated.Valid(milestoneData) =>
+                    val updatedMilestone =
+                      milestone.copy(
+                        title = milestoneData.title,
+                        description = milestoneData.description,
+                        dueDate = milestoneData.dueDate
+                      )
+                    for {
+                      checkDuplicate <- milestoneRepo.findMilestone(repoId)(updatedMilestone.title)
+                      resp <- checkDuplicate.filterNot(_.id === updatedMilestone.id) match {
+                        case None =>
+                          milestoneRepo.updateMilestone(updatedMilestone) *> SeeOther(
+                            Location(repositoryBaseUri.addSegment("milestones"))
+                          )
+                        case Some(_) =>
+                          BadRequest(
+                            views.html.editMilestone()(
+                              actionUri,
+                              csrf,
+                              milestone,
+                              repositoryBaseUri,
+                              s"Edit milestone ${milestone.title}".some,
+                              user,
+                              repo
+                            )(
+                              formData,
+                              Map(
+                                MilestoneForm.fieldTitle -> List(
+                                  FormFieldError("A milestone with that name already exists!")
+                                )
+                              )
+                            )
+                          )
+                      }
+                    } yield resp
+                }
+              } yield resp
+            case _ => NotFound()
+          }
+        } yield resp
+      }
+  }
+
+  private val showEditMilestoneForm: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter(
+          repositoryName
+        ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) / "edit" as user =>
+      for {
+        csrf      <- Sync[F].delay(ar.req.getCsrfToken)
+        repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
+        milestone <- repoAndId match {
+          case Some((_, repoId)) => milestoneRepo.findMilestone(repoId)(milestoneTitle)
+          case _                 => Sync[F].delay(None)
+        }
+        resp <- (repoAndId, milestone) match {
+          case (Some(repo, repoId), Some(milestone)) =>
+            for {
+              repositoryBaseUri <- Sync[F].delay(
+                linkConfig.createFullUri(
+                  Uri(path =
+                    Uri.Path(
+                      Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
+                    )
+                  )
+                )
+              )
+              actionUri <- Sync[F].delay(repositoryBaseUri.addSegment("milestone").addSegment(milestone.title.toString))
+              formData  <- Sync[F].delay(MilestoneForm.fromMilestone(milestone))
+              resp <- Ok(
+                views.html.editMilestone()(
+                  actionUri,
+                  csrf,
+                  milestone,
+                  repositoryBaseUri,
+                  s"Edit milestone ${milestone.title}".some,
+                  user,
+                  repo
+                )(
+                  formData.toMap
+                )
+              )
+            } yield resp
+          case _ => NotFound()
+        }
+      } yield resp
+  }
+
+  private val showEditMilestonesPage: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter(
+          repositoryName
+        ) / "milestones" as user =>
+      for {
+        csrf <- Sync[F].delay(ar.req.getCsrfToken)
+        resp <- doShowMilestones(csrf)(user.some)(repositoryOwnerName)(repositoryName)
+      } yield resp
+  }
+
+  private val showMilestonesForGuests: HttpRoutes[F] = HttpRoutes.of {
+    case req @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter(
+          repositoryName
+        ) / "milestones" =>
+      for {
+        csrf <- Sync[F].delay(req.getCsrfToken)
+        resp <- doShowMilestones(csrf)(None)(repositoryOwnerName)(repositoryName)
+      } yield resp
+  }
+
+  val protectedRoutes =
+    addMilestone <+> deleteMilestone <+> editMilestone <+> showEditMilestoneForm <+> showEditMilestonesPage
+
+  val routes = showMilestonesForGuests
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/UsernamePathParameter.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/UsernamePathParameter.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/UsernamePathParameter.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/UsernamePathParameter.scala	2025-01-31 10:46:28.178967320 +0000
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import de.smederee.security.Username
+
+/** Extractor to retrieve an Username from a path parameter.
+  */
+object UsernamePathParameter {
+  def unapply(str: String): Option[Username] =
+    Option(str).flatMap { string =>
+      if (string.startsWith("~"))
+        Username.from(string.drop(1))
+      else
+        None
+    }
+}
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 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,6 @@
+@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)
 @main(baseUri, lang)()(csrf, title, user.some) {
 @defining(lang.toLocale) { implicit locale =>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,5 +1,9 @@
 @import de.smederee.hub.AddPublicSshKeyForm._
+@import de.smederee.hub._
+@import de.smederee.hub.forms.types._
 @import de.smederee.ssh.PublicSshKey
+@import de.smederee.hub.views.html._
+@import de.smederee.hub.views.html.forms.renderFormErrors
 
 @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(actionBaseUri: Uri, addAction: Uri, deleteAction: Uri, keys: List[PublicSshKey])(formData: Map[String, String] = Map.empty, formErrors: FormErrors = FormErrors.empty)
 @main(baseUri, lang)()(csrf, title, user.some) {
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/contact.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/contact.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/contact.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/contact.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub._
+
 @(baseUri: Uri = Uri(path = Uri.Path.Root),
   lang: LanguageCode = LanguageCode("en")
 )(csrf: Option[CsrfToken] = None,
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/createRepository.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/createRepository.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/createRepository.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/createRepository.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,4 +1,7 @@
+@import de.smederee.hub._
+@import de.smederee.hub.forms.types._
 @import NewVcsRepositoryForm._
+@import de.smederee.hub.views.html.forms.renderFormErrors
 
 @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(action: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(formData: Map[String, String] = Map.empty, formErrors: FormErrors = FormErrors.empty)
 @main(baseUri, lang)()(csrf, title, user.some) {
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/csrfToken.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/csrfToken.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/csrfToken.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/csrfToken.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,2 +1,4 @@
+@import de.smederee.hub.config.Constants
+
 @(csrf: Option[CsrfToken])
 <input type="hidden" name="@Constants.csrfCookieName" value="@csrf">
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub._
+
 @(baseUri: Uri = Uri(path = Uri.Path.Root),
   lang: LanguageCode = LanguageCode("en")
 )(deleteAction: Uri,
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,4 +1,7 @@
+@import de.smederee.hub._
+@import de.smederee.hub.forms.types._
 @import EditVcsRepositoryForm._
+@import de.smederee.hub.views.html.forms.renderFormErrors
 
 @(baseUri: Uri = Uri(path = Uri.Path.Root),
   lang: LanguageCode = LanguageCode("en")
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt	2025-01-31 10:46:28.182967326 +0000
@@ -1,3 +1,4 @@
+@import de.smederee.hub._
 @(user: Account, validationToken: ValidationToken, validationBaseUri: Uri)
 Hello @{user.name},
 
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html	2025-01-31 10:46:28.182967326 +0000
@@ -1,3 +1,6 @@
+@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)
 @main(baseUri, lang)()(csrf, title, user.some) {
 @defining(lang.toLocale) { implicit locale =>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/forms/renderFormErrors.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/forms/renderFormErrors.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/forms/renderFormErrors.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/forms/renderFormErrors.scala.html	2025-01-31 10:46:28.182967326 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub.forms.types._
+
 @(field: FormField, errors: FormErrors)
 @errors.get(field).map { fieldErrors =>
   <div class="alert alert-warning">
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/imprint.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/imprint.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/imprint.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/imprint.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub._
+
 @(baseUri: Uri = Uri(path = Uri.Path.Root),
   lang: LanguageCode = LanguageCode("en")
 )(csrf: Option[CsrfToken] = None,
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/index.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/index.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/index.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/index.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,4 @@
+@import de.smederee.hub._
 @import SignupForm._
 
 @(baseUri: Uri = Uri(path = Uri.Path.Root),
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/login.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/login.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/login.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/login.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,4 +1,7 @@
+@import de.smederee.hub._
+@import de.smederee.hub.forms.types._
 @import LoginForm._
+@import de.smederee.hub.views.html.forms.renderFormErrors
 
 @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(action: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None)(formData: Map[String, String] = Map.empty, formErrors: FormErrors = FormErrors.empty)
 @main(baseUri, lang)()(csrf, title, user = None) {
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/main.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/main.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/main.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/main.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub.Account
+
 @(baseUri: Uri = Uri(path = Uri.Path.Root),
   lang: LanguageCode = LanguageCode("en"),
   tags: MetaTags = MetaTags.empty
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub._
+
 @(baseUri: Uri, lang: LanguageCode)(csrf: Option[CsrfToken] = None, extraCss: Option[String] = None, user: Option[Account] = None)
 @defining(lang.toLocale) { implicit locale =>
 <nav class="home-menu pure-menu pure-menu-horizontal pure-menu-scrollable @extraCss">
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/privacyPolicy.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/privacyPolicy.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/privacyPolicy.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/privacyPolicy.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub._
+
 @(baseUri: Uri = Uri(path = Uri.Path.Root),
   lang: LanguageCode = LanguageCode("en")
 )(csrf: Option[CsrfToken] = None,
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/publicAlpha.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/publicAlpha.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/publicAlpha.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/publicAlpha.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub._
+
 @(baseUri: Uri = Uri(path = Uri.Path.Root),
   lang: LanguageCode = LanguageCode("en")
 )(csrf: Option[CsrfToken] = None,
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub._
+
 @(actionBaseUri: Option[Uri], patch: VcsRepositoryPatchMetadata)(implicit locale: java.util.Locale)
 <div class="patch">
   <div class="patch-details">
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showAllRepositories.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showAllRepositories.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showAllRepositories.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showAllRepositories.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub._
+
 @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Option[Account])(listing: List[VcsRepository])
 @main(baseUri, lang)()(csrf, title, user) {
 @defining(lang.toLocale) { implicit locale =>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,6 @@
+@import de.smederee.hub._
+@import de.smederee.security.Username
+
 @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Option[Account])(listing: List[VcsRepository], repositoriesOwner: Username)
 @main(baseUri, lang)()(csrf, title, user) {
 @defining(lang.toLocale) { implicit locale =>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,5 +1,6 @@
 @import java.util.Locale
 @import de.smederee.hub.ToDoTextCssMapping._
+@import de.smederee.hub._
 
 @(baseUri: Uri,
   lang: LanguageCode = LanguageCode("en")
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub._
+
 @(baseUri: Uri,
   lang: LanguageCode = LanguageCode("en")
 )(actionBaseUri: Uri,
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub._
+
 @(baseUri: Uri
 )(activeUri: Option[Uri],
   repositoryBaseUri: Uri,
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub._
+
 @(baseUri: Uri,
   lang: LanguageCode = LanguageCode("en")
 )(actionBaseUri: Uri,
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub._
+
 @(baseUri: Uri,
   lang: LanguageCode = LanguageCode("en")
 )(actionBaseUri: Uri,
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/signup.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/signup.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/signup.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/signup.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,4 +1,7 @@
+@import de.smederee.hub._
+@import de.smederee.hub.forms.types._
 @import SignupForm._
+@import de.smederee.hub.views.html.forms.renderFormErrors
 
 @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(action: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None)(formData: Map[String, String] = Map.empty, formErrors: FormErrors = FormErrors.empty)
 @main(baseUri, lang)()(csrf, title, user = None) {
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/termsOfUse.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/termsOfUse.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/termsOfUse.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/termsOfUse.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub._
+
 @(baseUri: Uri = Uri(path = Uri.Path.Root),
   lang: LanguageCode = LanguageCode("en")
 )(csrf: Option[CsrfToken] = None,
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/welcome.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/welcome.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/welcome.scala.html	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/welcome.scala.html	2025-01-31 10:46:28.178967320 +0000
@@ -1,3 +1,5 @@
+@import de.smederee.hub._
+
 @(baseUri: Uri, lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None)
 @main(baseUri, lang)()(csrf, title, user = None) {
 @defining(lang.toLocale) { implicit locale =>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/csrfToken.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/csrfToken.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/csrfToken.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/csrfToken.scala.html	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,4 @@
+@import de.smederee.hub.config.Constants
+
+@(csrf: Option[CsrfToken])
+<input type="hidden" name="@Constants.csrfCookieName" value="@csrf">
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,92 @@
+@import de.smederee.hub.Account
+@import de.smederee.hub.views.html.main
+@import de.smederee.tickets.LabelForm._
+@import de.smederee.tickets._
+@import de.smederee.tickets.forms._
+@import de.smederee.tickets.forms.types._
+@import de.smederee.tickets.views.html.forms.renderFormErrors
+@import de.smederee.tickets.views.html.showProjectMenu
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root),
+  lang: LanguageCode = LanguageCode("en")
+)(action: Uri,
+  csrf: Option[CsrfToken] = None,
+  label: Label,
+  projectBaseUri: Uri,
+  title: Option[String] = None,
+  user: Account,
+  project: Project
+)(formData: Map[String, String] = Map.empty,
+  formErrors: FormErrors = FormErrors.empty
+)
+@main(baseUri, lang)()(csrf, title, user.some) {
+@defining(lang.toLocale) { implicit locale =>
+<div class="content">
+  <div class="pure-g">
+    <div class="pure-u-1">
+      <div class="l-box-left-right">
+        <h2><a href="@{baseUri.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2>
+        @showProjectMenu(baseUri)(projectBaseUri.addSegment("labels").some, projectBaseUri, user.some, project)
+        <div class="project-summary-description">
+          @Messages("project.labels.edit.title")
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="pure-g">
+    @if(ProjectOwnerId.fromUserId(user.uid) === project.owner.uid) {
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <h4>@Messages("project.label.edit.title", label.name)</h4>
+        <div class="form-errors">
+          @formErrors.get(fieldGlobal).map { es =>
+            @for(error <- es) {
+              <p class="alert alert-error">
+                <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+                <span class="sr-only">Fehler:</span>
+                @error
+              </p>
+            }
+          }
+        </div>
+        <div class="edit-labels-form">
+          <form action="@action" class="pure-form pure-form-aligned" method="POST" accept-charset="UTF-8">
+            <fieldset>
+              <input type="hidden" id="@fieldId" name="@fieldId" readonly="" value="@label.id">
+              <div class="pure-control-group">
+                <label for="@{fieldName}">@Messages("form.label.name")</label>
+                <input id="@{fieldName}" name="@{fieldName}" maxlength="40" placeholder="@Messages("form.label.name.placeholder")" required="" type="text" value="@{formData.get(fieldName)}">
+                <span class="pure-form-message" id="@{fieldName}.help" style="margin-left: 13em;">@Messages("form.label.name.help")</span>
+                @renderFormErrors(fieldName, formErrors)
+              </div>
+              <div class="pure-control-group">
+                <label for="@{fieldDescription}">@Messages("form.label.description")</label>
+                <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.label.description.placeholder")" type="text" value="@{formData.get(fieldDescription)}">
+                <span class="pure-form-message" id="@{fieldDescription}.help" style="margin-left: 13em;">@Messages("form.label.description.help")</span>
+                @renderFormErrors(fieldDescription, formErrors)
+              </div>
+              <div class="pure-control-group">
+                <label for="@{fieldColour}">@Messages("form.label.colour")</label>
+                <input id="@{fieldColour}" name="@{fieldColour}" required="" type="color" value="@{formData.get(fieldColour).getOrElse("#B48EAD")}">
+                <span class="pure-form-message" id="@{fieldColour}.help" style="margin-left: 13em;">@Messages("form.label.colour.help")</span>
+                @renderFormErrors(fieldColour, formErrors)
+              </div>
+              @csrfToken(csrf)
+              <button type="submit" class="pure-button pure-button-success">@Messages("form.label.edit.button.submit")</button>
+            </fieldset>
+          </form>
+        </div>
+      </div>
+    </div>
+    } else { }
+  </div>
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+      </div>
+    </div>
+  </div>
+</div>
+}
+}
+
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,130 @@
+@import de.smederee.hub.Account
+@import de.smederee.hub.views.html.main
+@import de.smederee.tickets.LabelForm._
+@import de.smederee.tickets._
+@import de.smederee.tickets.forms._
+@import de.smederee.tickets.forms.types._
+@import de.smederee.tickets.views.html.forms.renderFormErrors
+@import de.smederee.tickets.views.html.showProjectMenu
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root),
+  lang: LanguageCode = LanguageCode("en")
+)(action: Uri,
+  csrf: Option[CsrfToken] = None,
+  labels: List[Label],
+  projectBaseUri: Uri,
+  title: Option[String] = None,
+  user: Option[Account],
+  project: Project
+)(formData: Map[String, String] = Map.empty,
+  formErrors: FormErrors = FormErrors.empty
+)
+@main(baseUri, lang)()(csrf, title, user) {
+@defining(lang.toLocale) { implicit locale =>
+<div class="content">
+  <div class="pure-g">
+    <div class="pure-u-1">
+      <div class="l-box-left-right">
+        <h2><a href="@{baseUri.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2>
+        @showProjectMenu(baseUri)(action.some, projectBaseUri, user, project)
+        <div class="project-summary-description">
+          @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) {
+            @Messages("project.labels.edit.title")
+          } else {
+            @Messages("project.labels.view.title")
+          }
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="pure-g">
+    @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) {
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <h4>@Messages("project.labels.add.title")</h4>
+        <div class="form-errors">
+          @formErrors.get(fieldGlobal).map { es =>
+            @for(error <- es) {
+              <p class="alert alert-error">
+                <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+                <span class="sr-only">Fehler:</span>
+                @error
+              </p>
+            }
+          }
+        </div>
+        <div class="edit-labels-form">
+          <form action="@projectBaseUri.addSegment("labels")" class="pure-form pure-form-stacked" method="POST" accept-charset="UTF-8">
+            <fieldset>
+              <div class="pure-control-group">
+                <label for="@{fieldName}">@Messages("form.label.name")</label>
+                <input class="pure-input-3-4" id="@{fieldName}" name="@{fieldName}" maxlength="40" placeholder="@Messages("form.label.name.placeholder")" required="" type="text" value="@{formData.get(fieldName)}">
+                <span class="pure-form-message" id="@{fieldName}.help">@Messages("form.label.name.help")</span>
+                @renderFormErrors(fieldName, formErrors)
+              </div>
+              <div class="pure-control-group">
+                <label for="@{fieldDescription}">@Messages("form.label.description")</label>
+                <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.label.description.placeholder")" type="text" value="@{formData.get(fieldDescription)}">
+                <span class="pure-form-message" id="@{fieldDescription}.help">@Messages("form.label.description.help")</span>
+                @renderFormErrors(fieldDescription, formErrors)
+              </div>
+              <div class="pure-control-group">
+                <label for="@{fieldColour}">@Messages("form.label.colour")</label>
+                <input id="@{fieldColour}" name="@{fieldColour}" required="" type="color" value="@{formData.get(fieldColour).getOrElse("#B48EAD")}">
+                <span class="pure-form-message" id="@{fieldColour}.help">@Messages("form.label.colour.help")</span>
+                @renderFormErrors(fieldColour, formErrors)
+              </div>
+              @csrfToken(csrf)
+              <button type="submit" class="pure-button pure-button-success">@Messages("form.label.create.button.submit")</button>
+            </fieldset>
+          </form>
+        </div>
+      </div>
+    </div>
+    } else { }
+  </div>
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <div class="label-list">
+          <h4>@Messages("project.labels.list.title", labels.size)</h4>
+          @if(labels.size === 0) {
+            <div class="alert alert-info">@Messages("project.labels.list.empty")</div>
+          } else {
+            @defining(32) { lineHeight =>
+              @for(label <- labels) {
+                <div class="pure-g label">
+                  <div class="pure-u-1-24 label-icon" style="color: @label.colour;">
+                    @icon(baseUri)("tag", lineHeight.some)
+                  </div>
+                  <div class="pure-u-5-24 label-name" style="background: @label.colour; height: @{lineHeight}px; line-height: @{lineHeight}px;">@label.name</div>
+                  <div class="pure-u-8-24 label-description" style="height: @{lineHeight}px; line-height: @{lineHeight}px;">@label.description</div>
+                  <div class="pure-u-2-24" style="height: @{lineHeight}px; line-height: @{lineHeight}px; margin: auto;">
+                    @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) {
+                    <a class="pure-button" href="@projectBaseUri.addSegment("label").addSegment(label.name.toString).addSegment("edit")" title="@Messages("project.label.edit.title", label.name)">@Messages("project.label.edit.link")</a>
+                    } else { }
+                  </div>
+                  <div class="pure-u-8-24 label-form" style="height: @{lineHeight}px; line-height: @{lineHeight}px; padding-left: 1em;">
+                    @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) {
+                    <form action="@projectBaseUri.addSegment("label").addSegment(label.name.toString).addSegment("delete")" class="pure-form" method="POST" accept-charset="UTF-8">
+                      <fieldset>
+                        <input type="hidden" id="@fieldId-@label.name" name="@fieldId" readonly="" value="@label.id">
+                        <input type="hidden" id="@fieldName-@label.name" name="@fieldName" readonly="" value="@label.name">
+                        <label for="i-am-sure-@label.name"><input id="i-am-sure-@label.name" name="i-am-sure" required="" type="checkbox" value="yes"/> @Messages("form.label.delete.i-am-sure")</label>
+                        @csrfToken(csrf)
+                        <button type="submit" class="pure-button pure-button-warning">@Messages("form.label.delete.button.submit")</button>
+                      </fieldset>
+                    </form>
+                    } else { }
+                  </div>
+                </div>
+              }
+            }
+          }
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+}
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,92 @@
+@import de.smederee.hub.Account
+@import de.smederee.hub.views.html.main
+@import de.smederee.tickets.MilestoneForm._
+@import de.smederee.tickets._
+@import de.smederee.tickets.forms._
+@import de.smederee.tickets.forms.types._
+@import de.smederee.tickets.views.html.forms.renderFormErrors
+@import de.smederee.tickets.views.html.showProjectMenu
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root),
+  lang: LanguageCode = LanguageCode("en")
+)(action: Uri,
+  csrf: Option[CsrfToken] = None,
+  milestone: Milestone,
+  projectBaseUri: Uri,
+  title: Option[String] = None,
+  user: Account,
+  project: Project
+)(formData: Map[String, String] = Map.empty,
+  formErrors: FormErrors = FormErrors.empty
+)
+@main(baseUri, lang)()(csrf, title, user.some) {
+@defining(lang.toLocale) { implicit locale =>
+<div class="content">
+  <div class="pure-g">
+    <div class="pure-u-1">
+      <div class="l-box-left-right">
+        <h2><a href="@{baseUri.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2>
+        @showProjectMenu(baseUri)(projectBaseUri.addSegment("milestones").some, projectBaseUri, user.some, project)
+        <div class="project-summary-description">
+          @Messages("project.milestones.edit.title")
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="pure-g">
+    @if(ProjectOwnerId.fromUserId(user.uid) === project.owner.uid) {
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <h4>@Messages("project.milestone.edit.title", milestone.title)</h4>
+        <div class="form-errors">
+          @formErrors.get(fieldGlobal).map { es =>
+            @for(error <- es) {
+              <p class="alert alert-error">
+                <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+                <span class="sr-only">Fehler:</span>
+                @error
+              </p>
+            }
+          }
+        </div>
+        <div class="edit-milestones-form">
+          <form action="@action" class="pure-form pure-form-aligned" method="POST" accept-charset="UTF-8">
+            <fieldset>
+              <input type="hidden" id="@fieldId" name="@fieldId" readonly="" value="@milestone.id">
+              <div class="pure-control-group">
+                <milestone for="@{fieldTitle}">@Messages("form.milestone.title")</milestone>
+                <input id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData.get(fieldTitle)}">
+                <span class="pure-form-message" id="@{fieldTitle}.help" style="margin-left: 13em;">@Messages("form.milestone.title.help")</span>
+                @renderFormErrors(fieldTitle, formErrors)
+              </div>
+              <div class="pure-control-group">
+                <milestone for="@{fieldDescription}">@Messages("form.milestone.description")</milestone>
+                <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.milestone.description.placeholder")" type="text" value="@{formData.get(fieldDescription)}">
+                <span class="pure-form-message" id="@{fieldDescription}.help" style="margin-left: 13em;">@Messages("form.milestone.description.help")</span>
+                @renderFormErrors(fieldDescription, formErrors)
+              </div>
+              <div class="pure-control-group">
+                <milestone for="@{fieldDueDate}">@Messages("form.milestone.due-date")</milestone>
+                <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData.get(fieldDueDate)}">
+                <span class="pure-form-message" id="@{fieldDueDate}.help" style="margin-left: 13em;">@Messages("form.milestone.due-date.help")</span>
+                @renderFormErrors(fieldDueDate, formErrors)
+              </div>
+              @csrfToken(csrf)
+              <button type="submit" class="pure-button pure-button-success">@Messages("form.milestone.edit.button.submit")</button>
+            </fieldset>
+          </form>
+        </div>
+      </div>
+    </div>
+    } else { }
+  </div>
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+      </div>
+    </div>
+  </div>
+</div>
+}
+}
+
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,132 @@
+@import java.time._
+@import de.smederee.hub.Account
+@import de.smederee.hub.views.html.main
+@import de.smederee.tickets.MilestoneForm._
+@import de.smederee.tickets._
+@import de.smederee.tickets.forms._
+@import de.smederee.tickets.forms.types._
+@import de.smederee.tickets.views.html.format.formatDate
+@import de.smederee.tickets.views.html.forms.renderFormErrors
+@import de.smederee.tickets.views.html.showProjectMenu
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root),
+  lang: LanguageCode = LanguageCode("en")
+)(action: Uri,
+  csrf: Option[CsrfToken] = None,
+  milestones: List[Milestone],
+  projectBaseUri: Uri,
+  title: Option[String] = None,
+  user: Option[Account],
+  project: Project
+)(formData: Map[String, String] = Map.empty,
+  formErrors: FormErrors = FormErrors.empty
+)
+@main(baseUri, lang)()(csrf, title, user) {
+@defining(lang.toLocale) { implicit locale =>
+<div class="content">
+  <div class="pure-g">
+    <div class="pure-u-1">
+      <div class="l-box-left-right">
+        <h2><a href="@{baseUri.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2>
+        @showProjectMenu(baseUri)(action.some, projectBaseUri, user, project)
+        <div class="project-summary-description">
+          @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) {
+            @Messages("project.milestones.edit.title")
+          } else {
+            @Messages("project.milestones.view.title")
+          }
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="pure-g">
+    @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) {
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <h4>@Messages("project.milestones.add.title")</h4>
+        <div class="form-errors">
+          @formErrors.get(fieldGlobal).map { es =>
+            @for(error <- es) {
+              <p class="alert alert-error">
+                <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+                <span class="sr-only">Fehler:</span>
+                @error
+              </p>
+            }
+          }
+        </div>
+        <div class="edit-milestones-form">
+          <form action="@projectBaseUri.addSegment("milestones")" class="pure-form pure-form-stacked" method="POST" accept-charset="UTF-8">
+            <fieldset>
+              <div class="pure-control-group">
+                <label for="@{fieldTitle}">@Messages("form.milestone.title")</label>
+                <input class="pure-input-3-4" id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData.get(fieldTitle)}">
+                <span class="pure-form-message" id="@{fieldTitle}.help">@Messages("form.milestone.title.help")</span>
+                @renderFormErrors(fieldTitle, formErrors)
+              </div>
+              <div class="pure-control-group">
+                <label for="@{fieldDescription}">@Messages("form.milestone.description")</label>
+                <textarea class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" placeholder="@Messages("form.milestone.description.placeholder")" rows="4" value="@{formData.get(fieldDescription)}"></textarea>
+                <span class="pure-form-message" id="@{fieldDescription}.help">@Messages("form.milestone.description.help")</span>
+                @renderFormErrors(fieldDescription, formErrors)
+              </div>
+              <div class="pure-control-group">
+                <label for="@{fieldDueDate}">@Messages("form.milestone.due-date")</label>
+                <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData.get(fieldDueDate)}">
+                <span class="pure-form-message" id="@{fieldDueDate}.help">@Messages("form.milestone.due-date.help")</span>
+                @renderFormErrors(fieldDueDate, formErrors)
+              </div>
+              @csrfToken(csrf)
+              <button type="submit" class="pure-button pure-button-success">@Messages("form.milestone.create.button.submit")</button>
+            </fieldset>
+          </form>
+        </div>
+      </div>
+    </div>
+    } else { }
+  </div>
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <div class="milestone-list">
+          <h4>@Messages("project.milestones.list.title", milestones.size)</h4>
+          @if(milestones.size === 0) {
+            <div class="alert alert-info">@Messages("project.milestones.list.empty")</div>
+          } else {
+            @defining(32) { lineHeight =>
+              @for(milestone <- milestones) {
+                <div class="pure-g milestone">
+                  <div class="pure-u-1-24 milestone-icon">
+                    @icon(baseUri)("flag", lineHeight.some)
+                  </div>
+                  <div class="pure-u-5-24 milestone-title" style="height: @{lineHeight}px; line-height: @{lineHeight}px;">@milestone.title @for(dueDate <- milestone.dueDate) { @formatDate(dueDate) }</div>
+                  <div class="pure-u-8-24 milestone-description" style="height: @{lineHeight}px; line-height: @{lineHeight}px;">@milestone.description</div>
+                  <div class="pure-u-2-24" style="height: @{lineHeight}px; line-height: @{lineHeight}px; margin: auto;">
+                    @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) {
+                    <a class="pure-button" href="@projectBaseUri.addSegment("milestone").addSegment(milestone.title.toString).addSegment("edit")" title="@Messages("project.milestone.edit.title", milestone.title)">@Messages("project.milestone.edit.link")</a>
+                    } else { }
+                  </div>
+                  <div class="pure-u-8-24 milestone-form" style="height: @{lineHeight}px; line-height: @{lineHeight}px; padding-left: 1em;">
+                    @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) {
+                    <form action="@projectBaseUri.addSegment("milestone").addSegment(milestone.title.toString).addSegment("delete")" class="pure-form" method="POST" accept-charset="UTF-8">
+                      <fieldset>
+                        <input type="hidden" id="@fieldId-@milestone.title" name="@fieldId" readonly="" value="@milestone.id">
+                        <input type="hidden" id="@fieldTitle-@milestone.title" name="@fieldTitle" readonly="" value="@milestone.title">
+                        <milestone for="i-am-sure-@milestone.title"><input id="i-am-sure-@milestone.title" name="i-am-sure" required="" type="checkbox" value="yes"/> @Messages("form.milestone.delete.i-am-sure")</milestone>
+                        @csrfToken(csrf)
+                        <button type="submit" class="pure-button pure-button-warning">@Messages("form.milestone.delete.button.submit")</button>
+                      </fieldset>
+                    </form>
+                    } else { }
+                  </div>
+                </div>
+              }
+            }
+          }
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+}
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDate.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDate.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDate.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDate.scala.html	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,6 @@
+@import java.time._
+@import java.time.format._
+@import java.util.Locale
+
+@(date: LocalDate, style: FormatStyle = FormatStyle.MEDIUM)(implicit locale: Locale)
+(@DateTimeFormatter.ofLocalizedDate(style).withLocale(locale).format(date))
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/forms/renderFormErrors.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/forms/renderFormErrors.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/forms/renderFormErrors.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/forms/renderFormErrors.scala.html	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,12 @@
+@import de.smederee.tickets.forms.types._
+
+@(field: FormField, errors: FormErrors)
+@errors.get(field).map { fieldErrors =>
+  <div class="alert alert-warning">
+    <ul>
+      @for(error <- fieldErrors) {
+        <li><span>@error</span></li>
+      }
+    </ul>
+  </div>
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/icon.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/icon.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/icon.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/icon.scala.html	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,4 @@
+@(baseUri: Uri = Uri(path = Uri.Path.Root))(icon: String, overrideSize: Option[Int] = None)
+@defining(overrideSize.map(size => s"""style="height: ${size}px; width: ${size}px;"""").getOrElse("")) { sizeOverride =>
+<span class="feather-icon" aria-hidden="true"><svg class="feather-svg" @Html(sizeOverride)><use href="@{baseUri.addPath("assets/feather/4.29.0/feather-sprite.svg").withFragment(icon)}"/></svg></span>
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,32 @@
+@import de.smederee.hub._
+@import de.smederee.tickets.{ Project, ProjectOwnerId }
+
+@(baseUri: Uri
+)(activeUri: Option[Uri],
+  projectBaseUri: Uri,
+  user: Option[Account] = None,
+  project: Project
+)(implicit locale: java.util.Locale)
+<nav class="pure-menu pure-menu-horizontal">
+  <ul class="pure-menu-list">
+    @defining(projectBaseUri) { uri =>
+    <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("eye") @Messages("project.menu.overview")</a></li>
+    }
+    @defining(projectBaseUri.addSegment("labels")) { uri =>
+    <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("tag") @Messages("project.menu.labels")</a></li>
+    }
+    @defining(projectBaseUri.addSegment("milestones")) { uri =>
+    <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("flag") @Messages("project.menu.milestones")</a></li>
+    }
+    @if(activeUri.exists(uri => uri === projectBaseUri || uri === projectBaseUri.addSegment("edit") || uri === projectBaseUri.addSegment("delete"))) {
+      @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) {
+      @defining(projectBaseUri.addSegment("edit")) { uri =>
+        <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("edit-2") @Messages("project.menu.edit")</a></li>
+      }
+      @defining(projectBaseUri.addSegment("delete")) { uri =>
+        <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("trash-2") @Messages("project.menu.delete")</a></li>
+      }
+      } else { }
+    } else { }
+  </ul>
+</nav>
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala	2025-01-31 10:46:28.182967326 +0000
@@ -27,23 +27,21 @@
 import de.smederee.hub.forms._
 import de.smederee.hub.forms.types._
 import de.smederee.security.{ SignAndValidate, SignedToken }
-import fs2.Stream
 import org.http4s._
-import org.http4s.headers._
 import org.http4s.implicits._
 import org.http4s.server._
-import org.http4s.twirl.TwirlInstances._
 import pureconfig.ConfigSource
 
 import munit._
-import org.scalacheck._
-import org.scalacheck.Prop._
 
 class AuthenticationRoutesTest extends CatsEffectSuite {
   val loginPath = uri"/login"
 
   protected final val configuration: SmedereeHubConfig =
-    ConfigSource.fromConfig(ConfigFactory.load(getClass.getClassLoader)).loadOrThrow[SmedereeHubConfig]
+    ConfigSource
+      .fromConfig(ConfigFactory.load(getClass.getClassLoader))
+      .at(SmedereeHubConfig.location)
+      .loadOrThrow[SmedereeHubConfig]
 
   test("GET /login must return the login form for guest users") {
     val expectedHtml = views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)()
@@ -271,12 +269,6 @@
           .updateFormField(LoginForm.fieldPassword.toString, repo.DefaultPassword)
         def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload)
 
-        val expectedHtml =
-          views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)(
-            formData = Map(LoginForm.fieldName.toString -> account.name.toString),
-            Map(LoginForm.fieldGlobal -> List(FormFieldError("Invalid credentials!")))
-          )
-
         def response: IO[Response[IO]] = service.orNotFound.run(request)
 
         val test = for {
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/config/ServiceConfigTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/config/ServiceConfigTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/config/ServiceConfigTest.scala	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/config/ServiceConfigTest.scala	2025-01-31 10:46:28.182967326 +0000
@@ -34,7 +34,7 @@
   test("must load from the default configuration successfully") {
     ConfigSource
       .fromConfig(rawDefaultConfig())
-      .at(ServiceConfig.parentKey.toString)
+      .at(s"${SmedereeHubConfig.location.toString}.service")
       .load[ServiceConfig] match {
       case Left(errors) => fail(errors.toList.mkString(", "))
       case Right(_)     => assert(true)
@@ -44,7 +44,7 @@
   test("default configuration must have authentication enabled") {
     ConfigSource
       .fromConfig(rawDefaultConfig())
-      .at(ServiceConfig.parentKey.toString)
+      .at(s"${SmedereeHubConfig.location.toString}.service")
       .load[ServiceConfig] match {
       case Left(errors) => fail(errors.toList.mkString(", "))
       case Right(cfg)   => assert(cfg.authentication.enabled)
@@ -54,7 +54,7 @@
   test("default configuration must have billing disabled") {
     ConfigSource
       .fromConfig(rawDefaultConfig())
-      .at(ServiceConfig.parentKey.toString)
+      .at(s"${SmedereeHubConfig.location.toString}.service")
       .load[ServiceConfig] match {
       case Left(errors) => fail(errors.toList.mkString(", "))
       case Right(cfg)   => assert(cfg.billing.enabled === false)
@@ -64,7 +64,7 @@
   test("default configuration must have sign up enabled") {
     ConfigSource
       .fromConfig(rawDefaultConfig())
-      .at(ServiceConfig.parentKey.toString)
+      .at(s"${SmedereeHubConfig.location.toString}.service")
       .load[ServiceConfig] match {
       case Left(errors) => fail(errors.toList.mkString(", "))
       case Right(cfg)   => assert(cfg.signup.enabled)
@@ -74,7 +74,7 @@
   test("default values for external linking must be setup for local development") {
     ConfigSource
       .fromConfig(rawDefaultConfig())
-      .at(ServiceConfig.parentKey.toString)
+      .at(s"${SmedereeHubConfig.location.toString}.service")
       .load[ServiceConfig] match {
       case Left(errors) => fail(errors.toList.mkString(", "))
       case Right(cfg) =>
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 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-01-31 10:46:28.182967326 +0000
@@ -22,7 +22,8 @@
 import java.util.{ Locale, UUID }
 
 import cats.syntax.all._
-import de.smederee.security.{ PrivateKey, SignAndValidate }
+import de.smederee.email.EmailAddress
+import de.smederee.security._
 
 import org.scalacheck._
 
@@ -80,12 +81,19 @@
 
   val genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID)
 
-  val genValidEmail: Gen[Email] =
-    for {
-      length <- Gen.choose(4, 64)
-      chars  <- Gen.nonEmptyListOf(Gen.alphaNumChar)
-      email = chars.take(length).mkString
-    } yield Email(email + "@example.com")
+  private val validEmailAddressPrefixChars =
+    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.!#$%&’'*+/=?^_`{|}~-".toList
+  private val validDomainNameChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-".toList
+
+  val genValidEmail: Gen[EmailAddress] = for {
+    prefix <- Gen.nonEmptyListOf(Gen.oneOf(validEmailAddressPrefixChars)).map(_.take(64).mkString)
+    domain <- Gen.nonEmptyListOf(Gen.oneOf(validDomainNameChars)).map(_.take(32).mkString)
+    topLevelDomain <- Gen
+      .nonEmptyListOf(Gen.oneOf(validDomainNameChars))
+      .suchThat(_.length >= 2)
+      .map(_.take(24).mkString)
+    suffix = s"$domain.$topLevelDomain"
+  } yield EmailAddress(s"$prefix@$suffix")
 
   val genValidUsername: Gen[Username] = for {
     length <- Gen.choose(2, 30)
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/PasswordTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/PasswordTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/PasswordTest.scala	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/PasswordTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,64 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.hub
-
-import java.nio.charset.StandardCharsets
-
-import cats.syntax.all._
-import com.typesafe.config._
-import pureconfig._
-
-import munit._
-import org.scalacheck._
-import org.scalacheck.Prop._
-
-final class PasswordTest extends ScalaCheckSuite {
-  val genPassword: Gen[Password] =
-    Gen.nonEmptyListOf(Gen.alphaNumChar).map(s => Password(s.mkString.getBytes(StandardCharsets.UTF_8)))
-  given Arbitrary[Password] = Arbitrary(genPassword)
-
-  property("Password.from(null) must always be None") {
-    forAll { (_: String) =>
-      assertEquals(Password.from(null), None) // scalafix:ok
-    }
-  }
-
-  property("Password.from(string) must always be UTF8 bytes") {
-    forAll { (s: String) =>
-      val expected = s.getBytes(StandardCharsets.UTF_8)
-      Password.from(s) match {
-        case None => fail("Must produce valid byte array from non null string!")
-        case Some(p) =>
-          assert(p.toArray.sameElements(expected), "Generated byte array differs from expected one!")
-      }
-    }
-  }
-
-  property("Password.validate must fail on trimmed string with less than 12 characters") {
-    forAll { (s: String) =>
-      val input = s.take(11)
-      assert(Password.validate(input).isInvalid, "Passwords with less than 12 characters must be invalid!")
-    }
-  }
-
-  property("encode and matches must be commutative") {
-    forAll { (p: Password) =>
-      assert(p.matches(p.encode), "Encoded hash of same password must match!")
-    }
-  }
-}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala	2025-01-31 10:46:28.182967326 +0000
@@ -18,10 +18,12 @@
 package de.smederee.hub
 
 import java.nio.charset.StandardCharsets
-import java.util.UUID
 
 import cats.effect._
 import cats.syntax.all._
+import de.smederee.email.EmailAddress
+import de.smederee.hub._
+import de.smederee.security._
 
 /** An implementation of a [[AuthenticationRepository]] for testing purposes.
   *
@@ -85,7 +87,7 @@
 
   override def findAccount(uid: UserId): F[Option[Account]] = Sync[F].pure(accounts.headOption)
 
-  override def findAccountByEmail(email: Email): F[Option[Account]] = Sync[F].pure(accounts.headOption)
+  override def findAccountByEmail(email: EmailAddress): F[Option[Account]] = Sync[F].pure(accounts.headOption)
 
   override def unlockAccount(uid: UserId): F[Int] = {
     locked = locked.filterNot(_ === uid)
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/UserIdTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/UserIdTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/UserIdTest.scala	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/UserIdTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,41 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.hub
-
-import java.util.UUID
-
-import munit._
-import org.scalacheck._
-import org.scalacheck.Prop._
-
-final class UserIdTest extends ScalaCheckSuite {
-  private val genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID)
-  given Arbitrary[UUID]          = Arbitrary(genUUID)
-
-  property("UserId.fromString must fail on invalid input") {
-    forAll { (input: String) =>
-      assert(UserId.fromString(input).isLeft)
-    }
-  }
-
-  property("UserId.fromString must succeed on valid input") {
-    forAll { (uuid: UUID) =>
-      assert(UserId.fromString(uuid.toString).isRight)
-    }
-  }
-}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala	2025-01-31 10:46:28.162967293 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala	2025-01-31 10:46:28.182967326 +0000
@@ -21,6 +21,7 @@
 import java.util.UUID
 
 import de.smederee.hub._
+import de.smederee.security._
 
 import munit._
 
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/i18n/src/main/scala/de/smederee/i18n/LanguageCode.scala	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.i18n
+
+import scala.util.matching.Regex
+
+opaque type LanguageCode = String
+object LanguageCode {
+  val FormatIso639: Regex = "^[a-z]{2,3}$".r
+
+  /** Create an instance of LanguageCode from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a LanguageCode.
+    * @return
+    *   The appropriate instance of LanguageCode.
+    */
+  def apply(source: String): LanguageCode = source
+
+  /** Try to create an instance of LanguageCode from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a LanguageCode.
+    * @return
+    *   An option to the successfully converted LanguageCode.
+    */
+  def from(source: String): Option[LanguageCode] =
+    Option(source).map(FormatIso639.matches) match {
+      case Some(true) => Option(source)
+      case _          => None
+    }
+
+  extension (code: LanguageCode) {
+
+    /** Convert the language code into a [[java.util.Locale]] to be used for internationalisation functions.
+      *
+      * @return
+      *   A locale retrieved via the `forLanguageTag` method of the Java Locale implementation.
+      */
+    def toLocale: java.util.Locale = java.util.Locale.forLanguageTag(code.toString)
+  }
+}
diff -rN -u old-smederee/modules/i18n/src/test/scala/de/smederee/i18n/LanguageCodeTest.scala new-smederee/modules/i18n/src/test/scala/de/smederee/i18n/LanguageCodeTest.scala
--- old-smederee/modules/i18n/src/test/scala/de/smederee/i18n/LanguageCodeTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/i18n/src/test/scala/de/smederee/i18n/LanguageCodeTest.scala	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.i18n
+
+import java.util.Locale
+
+import munit._
+import org.scalacheck._
+import org.scalacheck.Prop._
+
+final class LanguageCodeTest extends ScalaCheckSuite {
+  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)
+  given Arbitrary[LanguageCode]          = Arbitrary(genLanguageCode)
+
+  property("from must work for correct language codes") {
+    forAll { (locale: Locale) =>
+      LanguageCode.from(locale.getISO3Language) match {
+        case None               => fail(s"No LanguageCode created from given input (locale: $locale)!")
+        case Some(languageCode) => assertEquals(languageCode.toString, locale.getISO3Language)
+      }
+    }
+  }
+
+  property("toLocale must return correct locales") {
+    forAll { (code: LanguageCode) =>
+      val expectedLocale = Locale.forLanguageTag(code.toString)
+      assertEquals(code.toLocale, expectedLocale)
+    }
+  }
+}
diff -rN -u old-smederee/modules/security/src/main/scala/de/smederee/security/CsrfToken.scala new-smederee/modules/security/src/main/scala/de/smederee/security/CsrfToken.scala
--- old-smederee/modules/security/src/main/scala/de/smederee/security/CsrfToken.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/security/src/main/scala/de/smederee/security/CsrfToken.scala	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.security
+
+opaque type CsrfToken = String
+object CsrfToken {
+
+  /** Create an instance of CsrfToken from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a CsrfToken.
+    * @return
+    *   The appropriate instance of CsrfToken.
+    */
+  def apply(source: String): CsrfToken = source
+
+  /** Try to create an instance of CsrfToken from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a CsrfToken.
+    * @return
+    *   An option to the successfully converted CsrfToken.
+    */
+  def from(source: String): Option[CsrfToken] =
+    Option(source).map(_.trim.nonEmpty) match {
+      case Some(true) => Option(source.trim)
+      case _          => None
+    }
+}
diff -rN -u old-smederee/modules/security/src/main/scala/de/smederee/security/PasswordHash.scala new-smederee/modules/security/src/main/scala/de/smederee/security/PasswordHash.scala
--- old-smederee/modules/security/src/main/scala/de/smederee/security/PasswordHash.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/security/src/main/scala/de/smederee/security/PasswordHash.scala	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.security
+
+import cats._
+
+opaque type PasswordHash = String
+object PasswordHash {
+  given Eq[PasswordHash] = Eq.fromUniversalEquals
+
+  /** Create an instance of PasswordHash from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a PasswordHash.
+    * @return
+    *   The appropriate instance of PasswordHash.
+    */
+  def apply(source: String): PasswordHash = source
+
+  /** Try to create an instance of PasswordHash from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a PasswordHash.
+    * @return
+    *   An option to the successfully converted PasswordHash.
+    */
+  def from(source: String): Option[PasswordHash] =
+    Option(source).map(_.trim.nonEmpty) match {
+      case Some(true) => Option(source.trim)
+      case _          => None
+    }
+}
diff -rN -u old-smederee/modules/security/src/main/scala/de/smederee/security/Password.scala new-smederee/modules/security/src/main/scala/de/smederee/security/Password.scala
--- old-smederee/modules/security/src/main/scala/de/smederee/security/Password.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/security/src/main/scala/de/smederee/security/Password.scala	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.security
+
+import java.nio.charset.StandardCharsets
+
+import cats._
+import cats.data._
+import cats.syntax.all._
+
+/** A password is stored as an `Array[Byte]` internally and its `validate(source: String)` function will check that the
+  * input has a minimum length.
+  */
+opaque type Password = Array[Byte]
+object Password {
+  // The minimum length of a password to be considered valid.
+  val MinimumLength: Int = 12
+  // The maximum length of a password might look contra-productive but it can prevent "long password denial of service attacks".
+  val MaximumLength: Int = 128
+
+  /** Create an instance of Password from the given Array[Byte] type.
+    *
+    * @param source
+    *   An instance of type Array[Byte] which will be returned as a Password.
+    * @return
+    *   The appropriate instance of Password.
+    */
+  def apply(source: Array[Byte]): Password = source
+
+  /** Try to create an instance of Password from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a Password.
+    * @return
+    *   An option to the successfully converted Password.
+    */
+  def from(source: String): Option[Password] =
+    Option(source) match {
+      case None         => None
+      case Some(string) => Option(string.getBytes(StandardCharsets.UTF_8))
+    }
+
+  /** Validate the given String against the criteria for a valid password.
+    *
+    * @param source
+    *   A string which should fulfil the password criteria.
+    * @return
+    *   Either a list of errors or the validated Password.
+    */
+  def validate(source: String): ValidatedNec[String, Password] =
+    Option(source).map(_.trim).filter(_.nonEmpty) match {
+      case Some(password) =>
+        if (password.length < MinimumLength)
+          s"Password must be at least $MinimumLength characters long!".invalidNec
+        else if (password.length > MaximumLength)
+          s"Password must not be longer than $MaximumLength characters!".invalidNec
+        else
+          password.getBytes(StandardCharsets.UTF_8).validNec
+      case _ => "Password must not be empty!".invalidNec
+    }
+
+  extension (p: Password) {
+    def toArray: Array[Byte] = p
+  }
+}
diff -rN -u old-smederee/modules/security/src/main/scala/de/smederee/security/PrivateKey.scala new-smederee/modules/security/src/main/scala/de/smederee/security/PrivateKey.scala
--- old-smederee/modules/security/src/main/scala/de/smederee/security/PrivateKey.scala	2025-01-31 10:46:28.166967299 +0000
+++ new-smederee/modules/security/src/main/scala/de/smederee/security/PrivateKey.scala	2025-01-31 10:46:28.182967326 +0000
@@ -17,10 +17,7 @@
 
 package de.smederee.security
 
-import javax.crypto.spec.SecretKeySpec
-
 opaque type PrivateKey = Array[Byte]
-
 object PrivateKey {
 
   /** Create an instance of PrivateKey from the given Array[Byte] type.
diff -rN -u old-smederee/modules/security/src/main/scala/de/smederee/security/UserId.scala new-smederee/modules/security/src/main/scala/de/smederee/security/UserId.scala
--- old-smederee/modules/security/src/main/scala/de/smederee/security/UserId.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/security/src/main/scala/de/smederee/security/UserId.scala	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.security
+
+import java.util.UUID
+
+import cats._
+import cats.syntax.all._
+
+import scala.util.matching.Regex
+
+/** A user id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath.
+  */
+opaque type UserId = UUID
+object UserId {
+  val Format: Regex = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r
+
+  given Eq[UserId] = Eq.fromUniversalEquals
+
+  /** Create an instance of UserId from the given UUID type.
+    *
+    * @param source
+    *   An instance of type UUID which will be returned as a UserId.
+    * @return
+    *   The appropriate instance of UserId.
+    */
+  def apply(source: UUID): UserId = source
+
+  /** Try to create an instance of UserId from the given UUID.
+    *
+    * @param source
+    *   A UUID that should fulfil the requirements to be converted into a UserId.
+    * @return
+    *   An option to the successfully converted UserId.
+    */
+  def from(source: UUID): Option[UserId] = Option(source)
+
+  /** Try to create an instance of UserId from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a UserId.
+    * @return
+    *   An option to the successfully converted UserId.
+    */
+  def fromString(source: String): Either[String, UserId] =
+    Option(source)
+      .filter(s => Format.matches(s))
+      .flatMap { uuidString =>
+        Either.catchNonFatal(UUID.fromString(uuidString)).toOption
+      }
+      .toRight("Illegal value for UserId!")
+
+  /** Generate a new random user id.
+    *
+    * @return
+    *   A user id which is pseudo randomly generated.
+    */
+  def randomUserId: UserId = UUID.randomUUID
+
+  extension (uid: UserId) {
+    def toUUID: UUID = uid
+  }
+}
diff -rN -u old-smederee/modules/security/src/main/scala/de/smederee/security/Username.scala new-smederee/modules/security/src/main/scala/de/smederee/security/Username.scala
--- old-smederee/modules/security/src/main/scala/de/smederee/security/Username.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/security/src/main/scala/de/smederee/security/Username.scala	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.security
+
+import cats._
+import cats.data._
+import cats.syntax.all._
+
+import scala.util.matching.Regex
+
+/** A username for an account has to obey several restrictions which are similiar to the ones found for Unix usernames.
+  * It must start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and
+  * be all lowercase.
+  */
+opaque type Username = String
+object Username {
+  given Eq[Username] = Eq.fromUniversalEquals
+
+  val MinimumLength: Int    = 2
+  val MaximumLength: Int    = 31
+  val isAlphanumeric: Regex = "^[a-z][a-z0-9]+$".r
+
+  /** Create an instance of Username from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a Username.
+    * @return
+    *   The appropriate instance of Username.
+    */
+  def apply(source: String): Username = source
+
+  /** Try to create an instance of Username from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a Username.
+    * @return
+    *   An option to the successfully converted Username.
+    */
+  def from(s: String): Option[Username] = validate(s).toOption
+
+  /** Validate the given string and return either the validated username or a list of errors.
+    *
+    * @param s
+    *   An arbitrary string which should be a username.
+    * @return
+    *   Either a list of errors or the validated username.
+    */
+  def validate(s: String): ValidatedNec[String, Username] =
+    Option(s).map(_.trim.nonEmpty) match {
+      case Some(true) =>
+        val input = s.trim
+        val miniumLength =
+          if (input.length >= MinimumLength)
+            input.validNec
+          else
+            s"Username too short (min. $MinimumLength characters)!".invalidNec
+        val maximumLength =
+          if (input.length <= MaximumLength)
+            input.validNec
+          else
+            s"Username too long (max. $MaximumLength characters)!".invalidNec
+        val alphanumeric =
+          if (isAlphanumeric.matches(input))
+            input.validNec
+          else
+            "Username must be all lowercase alphanumeric characters and start with a character.".invalidNec
+        (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) =>
+          name
+        }
+      case _ => "Username must not be empty!".invalidNec
+    }
+}
diff -rN -u old-smederee/modules/security/src/test/scala/de/smederee/security/PasswordTest.scala new-smederee/modules/security/src/test/scala/de/smederee/security/PasswordTest.scala
--- old-smederee/modules/security/src/test/scala/de/smederee/security/PasswordTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/security/src/test/scala/de/smederee/security/PasswordTest.scala	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.security
+
+import java.nio.charset.StandardCharsets
+
+import munit._
+import org.scalacheck._
+import org.scalacheck.Prop._
+
+final class PasswordTest extends ScalaCheckSuite {
+  property("Password.from(null) must always be None") {
+    forAll { (_: String) =>
+      assertEquals(Password.from(null), None) // scalafix:ok
+    }
+  }
+
+  property("Password.from(string) must always be UTF8 bytes") {
+    forAll { (s: String) =>
+      val expected = s.getBytes(StandardCharsets.UTF_8)
+      Password.from(s) match {
+        case None => fail("Must produce valid byte array from non null string!")
+        case Some(p) =>
+          assert(p.toArray.sameElements(expected), "Generated byte array differs from expected one!")
+      }
+    }
+  }
+
+  property(s"Password.validate must fail on trimmed string that is too long") {
+    forAll { (input: String) =>
+      if (input.trim.length > Password.MaximumLength) {
+        assert(
+          Password.validate(input).isInvalid,
+          s"Passwords with more than ${Password.MaximumLength} characters must be invalid!"
+        )
+      }
+    }
+  }
+
+  property(s"Password.validate must fail on trimmed string that is too short") {
+    forAll { (s: String) =>
+      val input = s.take(Password.MinimumLength).drop(1)
+      assert(
+        Password.validate(input).isInvalid,
+        s"Passwords with less than ${Password.MinimumLength} characters must be invalid!"
+      )
+    }
+  }
+}
diff -rN -u old-smederee/modules/security/src/test/scala/de/smederee/security/UserIdTest.scala new-smederee/modules/security/src/test/scala/de/smederee/security/UserIdTest.scala
--- old-smederee/modules/security/src/test/scala/de/smederee/security/UserIdTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/security/src/test/scala/de/smederee/security/UserIdTest.scala	2025-01-31 10:46:28.182967326 +0000
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.security
+
+import java.util.UUID
+
+import cats.syntax.all._
+
+import munit._
+import org.scalacheck._
+import org.scalacheck.Prop._
+
+final class UserIdTest extends ScalaCheckSuite {
+  private val genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID)
+  given Arbitrary[UUID]          = Arbitrary(genUUID)
+
+  property("UserId.fromString must fail on invalid input") {
+    forAll { (input: String) =>
+      assert(UserId.fromString(input).isLeft)
+    }
+  }
+
+  property("UserId.fromString must succeed on valid input") {
+    forAll { (uuid: UUID) =>
+      assert(UserId.fromString(uuid.toString).isRight)
+    }
+  }
+
+  property("Eq must hold") {
+    forAll { (id1: UUID, id2: UUID) =>
+      val uid1 = UserId(id1)
+      val uid2 = UserId(id2)
+
+      assert(uid1 === uid1, "Identical user ids must be considered equal!")
+      assert(uid1 =!= uid2, "Different user ids must not be considered equal!")
+    }
+  }
+}
diff -rN -u old-smederee/modules/tickets/src/it/resources/application.conf new-smederee/modules/tickets/src/it/resources/application.conf
--- old-smederee/modules/tickets/src/it/resources/application.conf	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/it/resources/application.conf	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,12 @@
+tickets {
+  database {
+	host = localhost
+	host = ${?SMEDEREE_DB_HOST}
+	url  = "jdbc:postgresql://"${tickets.database.host}":5432/smederee_tickets_it"
+	url  = ${?SMEDEREE_TICKETS_TEST_DB_URL}
+	user = "smederee_tickets"
+	user = ${?SMEDEREE_TICKETS_TEST_DB_USER}
+	pass = "secret"
+	pass = ${?SMEDEREE_TICKETS_TEST_DB_PASS}
+  }
+}
diff -rN -u old-smederee/modules/tickets/src/it/resources/logback-test.xml new-smederee/modules/tickets/src/it/resources/logback-test.xml
--- old-smederee/modules/tickets/src/it/resources/logback-test.xml	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/it/resources/logback-test.xml	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration debug="false">
+  <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
+    <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
+      <level>WARN</level>
+    </filter>
+    <encoder>
+      <pattern>%date %highlight(%-5level) %cyan(%logger{0}) - %msg%n</pattern>
+    </encoder>
+  </appender>
+
+  <appender name="async-console" class="ch.qos.logback.classic.AsyncAppender">
+    <appender-ref ref="console"/>
+    <queueSize>5000</queueSize>
+    <discardingThreshold>0</discardingThreshold>
+  </appender>
+
+  <logger name="de.smederee.tickets" level="DEBUG" additivity="false">
+    <appender-ref ref="async-console"/>
+  </logger>
+
+  <logger name="org.flywaydb.core" level="ERROR" additivity="false">
+	<appender-ref ref="async-console"/>
+  </logger>
+
+  <root>
+    <appender-ref ref="async-console"/>
+  </root>
+</configuration>
diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala
--- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,223 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import java.net.ServerSocket
+
+import cats.effect._
+import cats.syntax.all._
+import com.comcast.ip4s._
+import com.typesafe.config.ConfigFactory
+import de.smederee.tickets.config._
+import org.flywaydb.core.Flyway
+import pureconfig._
+
+import munit._
+
+/** Base class for our integration test suites.
+  *
+  * It loads and provides the configuration as a resource suit fixture (loaded once for all tests within a suite) and
+  * does initialise the test database for each suite. The latter means a possibly existing database with the name
+  * configured **will be deleted**!
+  */
+abstract class BaseSpec extends CatsEffectSuite {
+  protected final val configuration: SmedereeTicketsConfiguration =
+    ConfigSource
+      .fromConfig(ConfigFactory.load(getClass.getClassLoader))
+      .at(SmedereeTicketsConfiguration.location)
+      .loadOrThrow[SmedereeTicketsConfiguration]
+
+  protected final val flyway: Flyway =
+    DatabaseMigrator
+      .configureFlyway(configuration.database.url, configuration.database.user, configuration.database.pass)
+      .cleanDisabled(false)
+      .load()
+
+  /** Connect to the DBMS using the generic "template1" database which should always be present.
+    *
+    * @param dbConfig
+    *   The database configuration.
+    * @return
+    *   The connection to the database ("template1").
+    */
+  private def connect(dbConfig: DatabaseConfig): IO[java.sql.Connection] =
+    for {
+      _        <- IO(Class.forName(dbConfig.driver))
+      database <- IO(dbConfig.url.split("/").reverse.take(1).mkString)
+      connection <- IO(
+        java.sql.DriverManager
+          .getConnection(dbConfig.url.replace(database, "template1"), dbConfig.user, dbConfig.pass)
+      )
+    } yield connection
+
+  override def beforeAll(): Unit = {
+    // Extract the database name from the URL.
+    val database = configuration.database.url.split("/").reverse.take(1).mkString
+    val db       = Resource.make(connect(configuration.database))(con => IO(con.close()))
+    // Create the test database if it does not already exist.
+    db.use { connection =>
+      for {
+        statement <- IO(connection.createStatement())
+        exists <- IO(
+          statement.executeQuery(
+            s"""SELECT datname FROM pg_catalog.pg_database WHERE datname = '$database'"""
+          )
+        )
+        _ <- IO {
+          if (!exists.next())
+            statement.execute(s"""CREATE DATABASE "$database"""")
+        }
+        _ <- IO(exists.close)
+        _ <- IO(statement.close)
+      } yield ()
+    }.unsafeRunSync()
+  }
+
+  override def afterAll(): Unit = {
+    // Extract the database name from the URL.
+    val database = configuration.database.url.split("/").reverse.take(1).mkString
+    val db       = Resource.make(connect(configuration.database))(con => IO(con.close()))
+    // Drop the test database after all tests have been run.
+    db.use { connection =>
+      for {
+        statement <- IO(connection.createStatement())
+        _         <- IO(statement.execute(s"""DROP DATABASE IF EXISTS $database"""))
+        _         <- IO(statement.close)
+      } yield ()
+    }.unsafeRunSync()
+  }
+
+  override def beforeEach(context: BeforeEach): Unit = {
+    val _ = flyway.migrate()
+  }
+
+  override def afterEach(context: AfterEach): Unit = {
+    val _ = flyway.clean()
+  }
+
+  /** Find and return a free port on the local machine by starting a server socket and closing it. The port number used
+    * by the socket is marked to allow reuse, considered free and returned.
+    *
+    * @return
+    *   An optional port number if a free one can be found.
+    */
+  protected def findFreePort(): Option[Port] = {
+    val socket = new ServerSocket(0)
+    val port   = socket.getLocalPort
+    socket.setReuseAddress(true) // Allow instant rebinding of the socket.
+    socket.close()               // Free the socket for further use by closing it.
+    Port.fromInt(port)
+  }
+
+  /** Provide a resource with a database connection to allow db operations and proper resource release later.
+    *
+    * @param cfg
+    *   The application configuration.
+    * @return
+    *   A cats resource encapsulation a database connection as defined within the given configuration.
+    */
+  protected def connectToDb(cfg: SmedereeTicketsConfiguration): Resource[IO, java.sql.Connection] =
+    Resource.make(
+      IO.delay(java.sql.DriverManager.getConnection(cfg.database.url, cfg.database.user, cfg.database.pass))
+    )(c => IO.delay(c.close()))
+
+  /** Create a project for ticket tracking in the database.
+    *
+    * @param project
+    *   The project to be created.
+    * @return
+    *   The number of affected database rows.
+    */
+  @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+  protected def createTicketsProject(project: Project): IO[Int] =
+    connectToDb(configuration).use { con =>
+      for {
+        statement <- IO.delay {
+          con.prepareStatement(
+            """INSERT INTO "tickets"."projects" (name, owner, is_private, description, created_at, updated_at) VALUES(?, ?, ?, ?, NOW(), NOW())"""
+          )
+        }
+        _ <- IO.delay(statement.setString(1, project.name.toString))
+        _ <- IO.delay(statement.setObject(2, project.owner.uid))
+        _ <- IO.delay(statement.setBoolean(3, project.isPrivate))
+        _ <- IO.delay(
+          project.description.fold(statement.setNull(4, java.sql.Types.VARCHAR))(descr =>
+            statement.setString(4, descr.toString)
+          )
+        )
+        r <- IO.delay(statement.executeUpdate())
+        _ <- IO.delay(statement.close())
+      } yield r
+    }
+
+  /** Create a tickets user account in the database.
+    *
+    * @param user
+    *   The user to be created.
+    * @return
+    *   The number of affected database rows.
+    */
+  @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+  protected def createTicketsUser(user: ProjectOwner): IO[Int] =
+    connectToDb(configuration).use { con =>
+      for {
+        statement <- IO.delay {
+          con.prepareStatement(
+            """INSERT INTO "tickets"."users" (uid, name, email, created_at, updated_at) VALUES(?, ?, ?, NOW(), NOW())"""
+          )
+        }
+        _ <- IO.delay(statement.setObject(1, user.uid))
+        _ <- IO.delay(statement.setString(2, user.name.toString))
+        _ <- IO.delay(statement.setString(3, user.email.toString))
+        r <- IO.delay(statement.executeUpdate())
+        _ <- IO.delay(statement.close())
+      } yield r
+    }
+
+  /** Find the repository ID for the given owner and repository name.
+    *
+    * @param owner
+    *   The unique ID of the user account that owns the repository.
+    * @param name
+    *   The repository name which must be unique in regard to the owner.
+    * @return
+    *   An option to the internal database ID.
+    */
+  @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+  protected def loadProjectId(owner: ProjectOwnerId, name: ProjectName): IO[Option[Long]] =
+    connectToDb(configuration).use { con =>
+      for {
+        statement <- IO.delay(
+          con.prepareStatement(
+            """SELECT id FROM "tickets"."projects" WHERE owner = ? AND name = ? LIMIT 1"""
+          )
+        )
+        _      <- IO.delay(statement.setObject(1, owner))
+        _      <- IO.delay(statement.setString(2, name.toString))
+        result <- IO.delay(statement.executeQuery)
+        account <- IO.delay {
+          if (result.next()) {
+            Option(result.getLong("id"))
+          } else {
+            None
+          }
+        }
+        _ <- IO(statement.close())
+      } yield account
+    }
+}
diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala
--- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets.config
+
+import cats.effect._
+import cats.syntax.all._
+import org.flywaydb.core.Flyway
+
+import de.smederee.tickets.BaseSpec
+
+final class DatabaseMigratorTest extends BaseSpec {
+  override def beforeEach(context: BeforeEach): Unit = {
+    val dbConfig = configuration.database
+    val flyway: Flyway =
+      DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load()
+    val _ = flyway.migrate()
+    val _ = flyway.clean()
+  }
+
+  override def afterEach(context: AfterEach): Unit = {
+    val dbConfig = configuration.database
+    val flyway: Flyway =
+      DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load()
+    val _ = flyway.migrate()
+    val _ = flyway.clean()
+  }
+
+  test("DatabaseMigrator must update available outdated database") {
+    val dbConfig = configuration.database
+    val migrator = new DatabaseMigrator[IO]
+    val test     = migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass)
+    test.map(result => assert(result.migrationsExecuted > 0))
+  }
+
+  test("DatabaseMigrator must not update an up to date database") {
+    val dbConfig = configuration.database
+    val migrator = new DatabaseMigrator[IO]
+    val test = for {
+      _ <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass)
+      r <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass)
+    } yield r
+    test.map(result => assert(result.migrationsExecuted === 0))
+  }
+
+  test("DatabaseMigrator must throw an exception if the database is not available") {
+    val migrator = new DatabaseMigrator[IO]
+    val test     = migrator.migrate("jdbc:nodriver://", "", "")
+    test.attempt.map(r => assert(r.isLeft))
+  }
+}
diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala
--- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,268 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import cats.effect._
+import cats.syntax.all._
+import de.smederee.tickets.Generators._
+import doobie._
+
+final class DoobieLabelRepositoryTest extends BaseSpec {
+
+  /** Find the label ID for the given project and label name.
+    *
+    * @param owner
+    *   The unique ID of the user account that owns the project.
+    * @param vcsRepoName
+    *   The project name which must be unique in regard to the owner.
+    * @param labelName
+    *   The label name which must be unique in the project context.
+    * @return
+    *   An option to the internal database ID.
+    */
+  @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+  protected def findLabelId(owner: ProjectOwnerId, vcsRepoName: ProjectName, labelName: LabelName): IO[Option[Long]] =
+    connectToDb(configuration).use { con =>
+      for {
+        statement <- IO.delay(
+          con.prepareStatement(
+            """SELECT "labels".id
+              |FROM "tickets"."labels" AS "labels"
+              |JOIN "tickets"."projects" AS "projects"
+              |ON "labels".project = "projects".id
+              |WHERE "projects".owner = ?
+              |AND "projects".name = ?
+              |AND "labels".name = ?""".stripMargin
+          )
+        )
+        _      <- IO.delay(statement.setObject(1, owner))
+        _      <- IO.delay(statement.setString(2, vcsRepoName.toString))
+        _      <- IO.delay(statement.setString(3, labelName.toString))
+        result <- IO.delay(statement.executeQuery)
+        account <- IO.delay {
+          if (result.next()) {
+            Option(result.getLong("id"))
+          } else {
+            None
+          }
+        }
+        _ <- IO(statement.close())
+      } yield account
+    }
+
+  test("allLabels must return all labels") {
+    (genValidProjectOwner.sample, genValidProject.sample, genLabels.sample) match {
+      case (Some(owner), Some(generatedProject), Some(labels)) =>
+        val project   = generatedProject.copy(owner = owner)
+        val dbConfig  = configuration.database
+        val tx        = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val labelRepo = new DoobieLabelRepository[IO](tx)
+        val test = for {
+          _         <- createTicketsUser(owner)
+          _         <- createTicketsProject(project)
+          projectId <- loadProjectId(owner.uid, project.name)
+          createdLabels <- projectId match {
+            case None            => IO.pure(List.empty)
+            case Some(projectId) => labels.traverse(label => labelRepo.createLabel(projectId)(label))
+          }
+          foundLabels <- projectId match {
+            case None            => IO.pure(List.empty)
+            case Some(projectId) => labelRepo.allLabels(projectId).compile.toList
+          }
+        } yield foundLabels
+        test.map { foundLabels =>
+          assert(foundLabels.size === labels.size, "Different number of labels!")
+          foundLabels.sortBy(_.name).zip(labels.sortBy(_.name)).map { (found, expected) =>
+            assertEquals(found.copy(id = expected.id), expected)
+          }
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("createLabel must create the label") {
+    (genValidProjectOwner.sample, genValidProject.sample, genLabel.sample) match {
+      case (Some(owner), Some(generatedProject), Some(label)) =>
+        val project   = generatedProject.copy(owner = owner)
+        val dbConfig  = configuration.database
+        val tx        = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val labelRepo = new DoobieLabelRepository[IO](tx)
+        val test = for {
+          _               <- createTicketsUser(owner)
+          createdProjects <- createTicketsProject(project)
+          projectId       <- loadProjectId(owner.uid, project.name)
+          createdLabels   <- projectId.traverse(id => labelRepo.createLabel(id)(label))
+          foundLabel      <- projectId.traverse(id => labelRepo.findLabel(id)(label.name))
+        } yield (createdProjects, projectId, createdLabels, foundLabel)
+        test.map { tuple =>
+          val (createdProjects, projectId, createdLabels, foundLabel) = tuple
+          assert(createdProjects === 1, "Test project was not created!")
+          assert(projectId.nonEmpty, "No project id found!")
+          assert(createdLabels.exists(_ === 1), "Test label was not created!")
+          foundLabel.getOrElse(None) match {
+            case None => fail("Created label not found!")
+            case Some(foundLabel) =>
+              assertEquals(foundLabel.name, label.name)
+              assertEquals(foundLabel.description, label.description)
+              assertEquals(foundLabel.colour, label.colour)
+          }
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("createLabel must fail if the label name already exists") {
+    (genValidProjectOwner.sample, genValidProject.sample, genLabel.sample) match {
+      case (Some(owner), Some(generatedProject), Some(label)) =>
+        val project   = generatedProject.copy(owner = owner)
+        val dbConfig  = configuration.database
+        val tx        = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val labelRepo = new DoobieLabelRepository[IO](tx)
+        val test = for {
+          _               <- createTicketsUser(owner)
+          createdProjects <- createTicketsProject(project)
+          projectId       <- loadProjectId(owner.uid, project.name)
+          createdLabels   <- projectId.traverse(id => labelRepo.createLabel(id)(label))
+          _               <- projectId.traverse(id => labelRepo.createLabel(id)(label))
+        } yield (createdProjects, projectId, createdLabels)
+        test.attempt.map(r => assert(r.isLeft, "Creating a label with a duplicate name must fail!"))
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("deleteLabel must delete an existing label") {
+    (genValidProjectOwner.sample, genValidProject.sample, genLabel.sample) match {
+      case (Some(owner), Some(generatedProject), Some(label)) =>
+        val project   = generatedProject.copy(owner = owner)
+        val dbConfig  = configuration.database
+        val tx        = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val labelRepo = new DoobieLabelRepository[IO](tx)
+        val test = for {
+          _               <- createTicketsUser(owner)
+          createdProjects <- createTicketsProject(project)
+          projectId       <- loadProjectId(owner.uid, project.name)
+          createdLabels   <- projectId.traverse(id => labelRepo.createLabel(id)(label))
+          labelId         <- findLabelId(owner.uid, project.name, label.name)
+          deletedLabels   <- labelRepo.deleteLabel(label.copy(id = labelId.flatMap(LabelId.from)))
+          foundLabel      <- projectId.traverse(id => labelRepo.findLabel(id)(label.name))
+        } yield (createdProjects, projectId, createdLabels, deletedLabels, foundLabel)
+        test.map { tuple =>
+          val (createdProjects, projectId, createdLabels, deletedLabels, foundLabel) = tuple
+          assert(createdProjects === 1, "Test vcs project was not created!")
+          assert(projectId.nonEmpty, "No vcs project id found!")
+          assert(createdLabels.exists(_ === 1), "Test label was not created!")
+          assert(deletedLabels === 1, "Test label was not deleted!")
+          assert(foundLabel.getOrElse(None).isEmpty, "Test label was not deleted!")
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("findLabel must find existing labels") {
+    (genValidProjectOwner.sample, genValidProject.sample, genLabels.sample) match {
+      case (Some(owner), Some(generatedProject), Some(labels)) =>
+        val project       = generatedProject.copy(owner = owner)
+        val dbConfig      = configuration.database
+        val expectedLabel = labels(scala.util.Random.nextInt(labels.size))
+        val tx        = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val labelRepo = new DoobieLabelRepository[IO](tx)
+        val test = for {
+          _               <- createTicketsUser(owner)
+          createdProjects <- createTicketsProject(project)
+          projectId       <- loadProjectId(owner.uid, project.name)
+          createdLabels <- projectId match {
+            case None            => IO.pure(List.empty)
+            case Some(projectId) => labels.traverse(label => labelRepo.createLabel(projectId)(label))
+          }
+          foundLabel <- projectId.traverse(id => labelRepo.findLabel(id)(expectedLabel.name))
+        } yield foundLabel.flatten
+        test.map { foundLabel =>
+          assertEquals(foundLabel.map(_.copy(id = expectedLabel.id)), Option(expectedLabel))
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("updateLabel must update an existing label") {
+    (genValidProjectOwner.sample, genValidProject.sample, genLabel.sample) match {
+      case (Some(owner), Some(generatedProject), Some(label)) =>
+        val updatedLabel = label.copy(
+          name = LabelName("updated label"),
+          description = Option(LabelDescription("I am an updated label description...")),
+          colour = ColourCode("#abcdef")
+        )
+        val project   = generatedProject.copy(owner = owner)
+        val dbConfig  = configuration.database
+        val tx        = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val labelRepo = new DoobieLabelRepository[IO](tx)
+        val test = for {
+          _               <- createTicketsUser(owner)
+          createdProjects <- createTicketsProject(project)
+          projectId       <- loadProjectId(owner.uid, project.name)
+          createdLabels   <- projectId.traverse(id => labelRepo.createLabel(id)(label))
+          labelId         <- findLabelId(owner.uid, project.name, label.name)
+          updatedLabels   <- labelRepo.updateLabel(updatedLabel.copy(id = labelId.map(LabelId.apply)))
+          foundLabel      <- projectId.traverse(id => labelRepo.findLabel(id)(updatedLabel.name))
+        } yield (createdProjects, projectId, createdLabels, updatedLabels, foundLabel.flatten)
+        test.map { tuple =>
+          val (createdProjects, projectId, createdLabels, updatedLabels, foundLabel) = tuple
+          assert(createdProjects === 1, "Test vcs project was not created!")
+          assert(projectId.nonEmpty, "No vcs project id found!")
+          assert(createdLabels.exists(_ === 1), "Test label was not created!")
+          assert(updatedLabels === 1, "Test label was not updated!")
+          assert(foundLabel.nonEmpty, "Updated label not found!")
+          foundLabel.map { label =>
+            assertEquals(label, updatedLabel.copy(id = label.id))
+          }
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("updateLabel must do nothing if id attribute is empty") {
+    (genValidProjectOwner.sample, genValidProject.sample, genLabel.sample) match {
+      case (Some(owner), Some(generatedProject), Some(label)) =>
+        val updatedLabel = label.copy(
+          id = None,
+          name = LabelName("updated label"),
+          description = Option(LabelDescription("I am an updated label description...")),
+          colour = ColourCode("#abcdef")
+        )
+        val project   = generatedProject.copy(owner = owner)
+        val dbConfig  = configuration.database
+        val tx        = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val labelRepo = new DoobieLabelRepository[IO](tx)
+        val test = for {
+          _               <- createTicketsUser(owner)
+          createdProjects <- createTicketsProject(project)
+          projectId       <- loadProjectId(owner.uid, project.name)
+          createdLabels   <- projectId.traverse(id => labelRepo.createLabel(id)(label))
+          labelId         <- findLabelId(owner.uid, project.name, label.name)
+          updatedLabels   <- labelRepo.updateLabel(updatedLabel)
+        } yield (createdProjects, projectId, createdLabels, updatedLabels)
+        test.map { tuple =>
+          val (createdProjects, projectId, createdLabels, updatedLabels) = tuple
+          assert(createdProjects === 1, "Test vcs project was not created!")
+          assert(projectId.nonEmpty, "No vcs project id found!")
+          assert(createdLabels.exists(_ === 1), "Test label was not created!")
+          assert(updatedLabels === 0, "Label with empty id must not be updated!")
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+}
diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala
--- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,267 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import java.time._
+
+import cats.effect._
+import cats.syntax.all._
+import de.smederee.tickets.Generators._
+import doobie._
+
+final class DoobieMilestoneRepositoryTest extends BaseSpec {
+
+  /** Find the milestone ID for the given repository and milestone title.
+    *
+    * @param owner
+    *   The unique ID of the user owner that owns the repository.
+    * @param projectName
+    *   The project name which must be unique in regard to the owner.
+    * @param title
+    *   The milestone title which must be unique in the repository context.
+    * @return
+    *   An option to the internal database ID.
+    */
+  @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+  protected def findMilestoneId(
+      owner: ProjectOwnerId,
+      projectName: ProjectName,
+      title: MilestoneTitle
+  ): IO[Option[Long]] =
+    connectToDb(configuration).use { con =>
+      for {
+        statement <- IO.delay(
+          con.prepareStatement(
+            """SELECT "milestones".id FROM "tickets"."milestones" AS "milestones" JOIN "tickets"."projects" AS "projects" ON "milestones".project = "projects".id WHERE "projects".owner = ? AND "projects".name = ? AND "milestones".title = ?"""
+          )
+        )
+        _      <- IO.delay(statement.setObject(1, owner))
+        _      <- IO.delay(statement.setString(2, projectName.toString))
+        _      <- IO.delay(statement.setString(3, title.toString))
+        result <- IO.delay(statement.executeQuery)
+        owner <- IO.delay {
+          if (result.next()) {
+            Option(result.getLong("id"))
+          } else {
+            None
+          }
+        }
+        _ <- IO(statement.close())
+      } yield owner
+    }
+
+  test("allMilestones must return all milestones") {
+    (genValidProjectOwner.sample, genValidProject.sample, genMilestones.sample) match {
+      case (Some(owner), Some(generatedProject), Some(milestones)) =>
+        val project  = generatedProject.copy(owner = owner)
+        val dbConfig = configuration.database
+        val tx       = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val test = for {
+          _            <- createTicketsUser(owner)
+          createdRepos <- createTicketsProject(project)
+          repoId       <- loadProjectId(owner.uid, project.name)
+          createdMilestones <- repoId match {
+            case None         => IO.pure(List.empty)
+            case Some(repoId) => milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone))
+          }
+          foundMilestones <- repoId match {
+            case None         => IO.pure(List.empty)
+            case Some(repoId) => milestoneRepo.allMilestones(repoId).compile.toList
+          }
+        } yield foundMilestones
+        test.map { foundMilestones =>
+          assert(foundMilestones.size === milestones.size, "Different number of milestones!")
+          foundMilestones.sortBy(_.title).zip(milestones.sortBy(_.title)).map { (found, expected) =>
+            assertEquals(found.copy(id = expected.id), expected)
+          }
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("createMilestone must create the milestone") {
+    (genValidProjectOwner.sample, genValidProject.sample, genMilestone.sample) match {
+      case (Some(owner), Some(generatedProject), Some(milestone)) =>
+        val project  = generatedProject.copy(owner = owner)
+        val dbConfig = configuration.database
+        val tx       = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val test = for {
+          _                 <- createTicketsUser(owner)
+          createdRepos      <- createTicketsProject(project)
+          repoId            <- loadProjectId(owner.uid, project.name)
+          createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+          foundMilestone    <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title))
+        } yield (createdRepos, repoId, createdMilestones, foundMilestone)
+        test.map { tuple =>
+          val (createdRepos, repoId, createdMilestones, foundMilestone) = tuple
+          assert(createdRepos === 1, "Test vcs generatedProject was not created!")
+          assert(repoId.nonEmpty, "No vcs generatedProject id found!")
+          assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
+          foundMilestone.getOrElse(None) match {
+            case None                 => fail("Created milestone not found!")
+            case Some(foundMilestone) => assertEquals(foundMilestone, milestone.copy(id = foundMilestone.id))
+          }
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("createMilestone must fail if the milestone name already exists") {
+    (genValidProjectOwner.sample, genValidProject.sample, genMilestone.sample) match {
+      case (Some(owner), Some(generatedProject), Some(milestone)) =>
+        val project  = generatedProject.copy(owner = owner)
+        val dbConfig = configuration.database
+        val tx       = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val test = for {
+          _                 <- createTicketsUser(owner)
+          createdRepos      <- createTicketsProject(project)
+          repoId            <- loadProjectId(owner.uid, project.name)
+          createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+          _                 <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+        } yield (createdRepos, repoId, createdMilestones)
+        test.attempt.map(r => assert(r.isLeft, "Creating a milestone with a duplicate name must fail!"))
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("deleteMilestone must delete an existing milestone") {
+    (genValidProjectOwner.sample, genValidProject.sample, genMilestone.sample) match {
+      case (Some(owner), Some(generatedProject), Some(milestone)) =>
+        val project  = generatedProject.copy(owner = owner)
+        val dbConfig = configuration.database
+        val tx       = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val test = for {
+          _                 <- createTicketsUser(owner)
+          createdRepos      <- createTicketsProject(project)
+          repoId            <- loadProjectId(owner.uid, project.name)
+          createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+          milestoneId       <- findMilestoneId(owner.uid, project.name, milestone.title)
+          deletedMilestones <- milestoneRepo.deleteMilestone(milestone.copy(id = milestoneId.flatMap(MilestoneId.from)))
+          foundMilestone    <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title))
+        } yield (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone)
+        test.map { tuple =>
+          val (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone) = tuple
+          assert(createdRepos === 1, "Test vcs generatedProject was not created!")
+          assert(repoId.nonEmpty, "No vcs generatedProject id found!")
+          assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
+          assert(deletedMilestones === 1, "Test milestone was not deleted!")
+          assert(foundMilestone.getOrElse(None).isEmpty, "Test milestone was not deleted!")
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("findMilestone must find existing milestones") {
+    (genValidProjectOwner.sample, genValidProject.sample, genMilestones.sample) match {
+      case (Some(owner), Some(generatedProject), Some(milestones)) =>
+        val project           = generatedProject.copy(owner = owner)
+        val dbConfig          = configuration.database
+        val expectedMilestone = milestones(scala.util.Random.nextInt(milestones.size))
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val test = for {
+          _            <- createTicketsUser(owner)
+          createdRepos <- createTicketsProject(project)
+          repoId       <- loadProjectId(owner.uid, project.name)
+          createdMilestones <- repoId match {
+            case None         => IO.pure(List.empty)
+            case Some(repoId) => milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone))
+          }
+          foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(expectedMilestone.title))
+        } yield foundMilestone.flatten
+        test.map { foundMilestone =>
+          assertEquals(foundMilestone.map(_.copy(id = expectedMilestone.id)), Option(expectedMilestone))
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("updateMilestone must update an existing milestone") {
+    (genValidProjectOwner.sample, genValidProject.sample, genMilestone.sample) match {
+      case (Some(owner), Some(generatedProject), Some(milestone)) =>
+        val updatedMilestone = milestone.copy(
+          title = MilestoneTitle("updated milestone"),
+          description = Option(MilestoneDescription("I am an updated milestone description...")),
+          dueDate = Option(LocalDate.of(1879, 3, 14))
+        )
+        val project  = generatedProject.copy(owner = owner)
+        val dbConfig = configuration.database
+        val tx       = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val test = for {
+          _                 <- createTicketsUser(owner)
+          createdRepos      <- createTicketsProject(project)
+          repoId            <- loadProjectId(owner.uid, project.name)
+          createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+          milestoneId       <- findMilestoneId(owner.uid, project.name, milestone.title)
+          updatedMilestones <- milestoneRepo.updateMilestone(
+            updatedMilestone.copy(id = milestoneId.map(MilestoneId.apply))
+          )
+          foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(updatedMilestone.title))
+        } yield (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone.flatten)
+        test.map { tuple =>
+          val (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone) = tuple
+          assert(createdRepos === 1, "Test vcs generatedProject was not created!")
+          assert(repoId.nonEmpty, "No vcs generatedProject id found!")
+          assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
+          assert(updatedMilestones === 1, "Test milestone was not updated!")
+          assert(foundMilestone.nonEmpty, "Updated milestone not found!")
+          foundMilestone.map { milestone =>
+            assertEquals(milestone, updatedMilestone.copy(id = milestone.id))
+          }
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("updateMilestone must do nothing if id attribute is empty") {
+    (genValidProjectOwner.sample, genValidProject.sample, genMilestone.sample) match {
+      case (Some(owner), Some(generatedProject), Some(milestone)) =>
+        val updatedMilestone = milestone.copy(
+          id = None,
+          title = MilestoneTitle("updated milestone"),
+          description = Option(MilestoneDescription("I am an updated milestone description...")),
+          dueDate = Option(LocalDate.of(1879, 3, 14))
+        )
+        val project  = generatedProject.copy(owner = owner)
+        val dbConfig = configuration.database
+        val tx       = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val test = for {
+          _                 <- createTicketsUser(owner)
+          createdRepos      <- createTicketsProject(project)
+          repoId            <- loadProjectId(owner.uid, project.name)
+          createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+          milestoneId       <- findMilestoneId(owner.uid, project.name, milestone.title)
+          updatedMilestones <- milestoneRepo.updateMilestone(updatedMilestone)
+        } yield (createdRepos, repoId, createdMilestones, updatedMilestones)
+        test.map { tuple =>
+          val (createdRepos, repoId, createdMilestones, updatedMilestones) = tuple
+          assert(createdRepos === 1, "Test vcs generatedProject was not created!")
+          assert(repoId.nonEmpty, "No vcs generatedProject id found!")
+          assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
+          assert(updatedMilestones === 0, "Milestone with empty id must not be updated!")
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+}
diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala
--- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import java.time._
+import java.util.{ Locale, UUID }
+
+import cats._
+import cats.syntax.all._
+import de.smederee.email.EmailAddress
+
+import org.scalacheck.{ Arbitrary, Gen }
+
+import scala.jdk.CollectionConverters._
+
+object Generators {
+  val MinimumYear: Int = -4713  // Lowest year supported by PostgreSQL
+  val MaximumYear: Int = 294276 // Highest year supported by PostgreSQL
+
+  /** Prepend a zero to a single character hexadecimal code.
+    *
+    * @param hexCode
+    *   A string supposed to contain a hexadecimal code between 0 and ff.
+    * @return
+    *   Either the given code prepended with a leading zero if it had only a single character or the originally given
+    *   code otherwise.
+    */
+  private def hexPadding(hexCode: String): String =
+    if (hexCode.length < 2)
+      "0" + hexCode
+    else
+      hexCode
+
+  val genLocalDate: Gen[LocalDate] =
+    for {
+      year  <- Gen.choose(MinimumYear, MaximumYear)
+      month <- Gen.choose(1, 12)
+      day   <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap))
+    } yield LocalDate.of(year, month, day)
+
+  given Arbitrary[LocalDate] = Arbitrary(genLocalDate)
+
+  val genOffsetDateTime: Gen[OffsetDateTime] =
+    for {
+      year       <- Gen.choose(MinimumYear, MaximumYear)
+      month      <- Gen.choose(1, 12)
+      day        <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap))
+      hour       <- Gen.choose(0, 23)
+      minute     <- Gen.choose(0, 59)
+      second     <- Gen.choose(0, 59)
+      nanosecond <- Gen.const(0) // Avoid issues with loosing information during saving and loading.
+      offset <- Gen.oneOf(
+        ZoneId.getAvailableZoneIds.asScala.toList.map(ZoneId.of).map(ZonedDateTime.now).map(_.getOffset)
+      )
+    } yield OffsetDateTime.of(year, month, day, hour, minute, second, nanosecond, offset)
+
+  given Arbitrary[OffsetDateTime] = Arbitrary(genOffsetDateTime)
+
+  val genProjectOwnerId: Gen[ProjectOwnerId] = Gen.delay(ProjectOwnerId.randomProjectOwnerId)
+
+  val genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID)
+
+  val genValidEmailAddress: Gen[EmailAddress] =
+    for {
+      length <- Gen.choose(4, 64)
+      chars  <- Gen.nonEmptyListOf(Gen.alphaNumChar)
+      email = chars.take(length).mkString
+    } yield EmailAddress(email + "@example.com")
+
+  val genValidProjectOwnerName: Gen[ProjectOwnerName] = for {
+    length <- Gen.choose(2, 30)
+    prefix <- Gen.alphaChar
+    chars <- Gen
+      .nonEmptyListOf(Gen.alphaNumChar)
+      .map(_.take(length).mkString.toLowerCase(Locale.ROOT))
+  } yield ProjectOwnerName(prefix.toString.toLowerCase(Locale.ROOT) + chars)
+
+  val genValidProjectOwner: Gen[ProjectOwner] = for {
+    id    <- genProjectOwnerId
+    name  <- genValidProjectOwnerName
+    email <- genValidEmailAddress
+  } yield ProjectOwner(uid = id, name = name, email = email)
+
+  given Arbitrary[ProjectOwner] = Arbitrary(genValidProjectOwner)
+
+  val genValidProjectOwners: Gen[List[ProjectOwner]] = Gen
+    .nonEmptyListOf(genValidProjectOwner)
+    .suchThat(owners => owners.size === owners.map(_.name).distinct.size) // Ensure unique names.
+
+  val genLabelName: Gen[LabelName] =
+    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelName.MaxLength).mkString).map(LabelName.apply)
+
+  val genLabelDescription: Gen[LabelDescription] =
+    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelDescription.MaxLength).mkString).map(LabelDescription.apply)
+
+  val genColourCode: Gen[ColourCode] = for {
+    red   <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
+    green <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
+    blue  <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
+    hexString = s"#$red$green$blue"
+  } yield ColourCode(hexString)
+
+  val genLabel: Gen[Label] = for {
+    id          <- Gen.option(Gen.choose(0L, Long.MaxValue).map(LabelId.apply))
+    name        <- genLabelName
+    description <- Gen.option(genLabelDescription)
+    colour      <- genColourCode
+  } yield Label(id, name, description, colour)
+
+  val genLabels: Gen[List[Label]] = Gen.nonEmptyListOf(genLabel).map(_.distinct)
+
+  val genMilestoneTitle: Gen[MilestoneTitle] =
+    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(MilestoneTitle.MaxLength).mkString).map(MilestoneTitle.apply)
+
+  val genMilestoneDescription: Gen[MilestoneDescription] =
+    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.mkString).map(MilestoneDescription.apply)
+
+  val genMilestone: Gen[Milestone] =
+    for {
+      id    <- Gen.option(Gen.choose(0L, Long.MaxValue).map(MilestoneId.apply))
+      title <- genMilestoneTitle
+      due   <- Gen.option(genLocalDate)
+      descr <- Gen.option(genMilestoneDescription)
+    } yield Milestone(id, title, descr, due)
+
+  val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct)
+
+  val genValidProjectName: Gen[ProjectName] = Gen
+    .nonEmptyListOf(
+      Gen.oneOf(
+        List(
+          "a",
+          "b",
+          "c",
+          "d",
+          "e",
+          "f",
+          "g",
+          "h",
+          "i",
+          "j",
+          "k",
+          "l",
+          "m",
+          "n",
+          "o",
+          "p",
+          "q",
+          "r",
+          "s",
+          "t",
+          "u",
+          "v",
+          "w",
+          "x",
+          "y",
+          "z",
+          "0",
+          "1",
+          "2",
+          "3",
+          "4",
+          "5",
+          "6",
+          "7",
+          "8",
+          "9",
+          "-",
+          "_"
+        )
+      )
+    )
+    .map(cs => ProjectName(cs.take(64).mkString))
+
+  val genProjectDescription: Gen[Option[ProjectDescription]] =
+    Gen.alphaNumStr.map(_.take(ProjectDescription.MaximumLength)).map(ProjectDescription.from)
+
+  val genValidProject: Gen[Project] =
+    for {
+      name        <- genValidProjectName
+      description <- genProjectDescription
+      owner       <- genValidProjectOwner
+      isPrivate   <- Gen.oneOf(List(false, true))
+    } yield Project(owner, name, description, isPrivate)
+
+  val genValidProjects: Gen[List[Project]] = Gen.nonEmptyListOf(genValidProject)
+
+}
diff -rN -u old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql
--- old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,3 @@
+CREATE SCHEMA IF NOT EXISTS "tickets";
+
+COMMENT ON SCHEMA "tickets" IS 'Data related to ticket tracking.';
diff -rN -u old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql
--- old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,200 @@
+CREATE TABLE "tickets"."users"
+(
+  "uid"              UUID                     NOT NULL,
+  "name"             CHARACTER VARYING(32)    NOT NULL,
+  "email"            CHARACTER VARYING(128)   NOT NULL,
+  "created_at"       TIMESTAMP WITH TIME ZONE NOT NULL,
+  "updated_at"       TIMESTAMP WITH TIME ZONE NOT NULL,
+  CONSTRAINT "users_pk"           PRIMARY KEY ("uid"),
+  CONSTRAINT "users_unique_name"  UNIQUE ("name"),
+  CONSTRAINT "users_unique_email" UNIQUE ("email")
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE "tickets"."users" IS 'All users for the ticket system live within this table.';
+COMMENT ON COLUMN "tickets"."users"."uid" IS 'A globally unique ID for the related user account. It must match the user ID from the hub account.';
+COMMENT ON COLUMN "tickets"."users"."name" IS 'A username between 2 and 32 characters which must be globally unique, contain only lowercase alphanumeric characters and start with a character.';
+COMMENT ON COLUMN "tickets"."users"."email" IS 'A globally unique email address associated with the account.';
+COMMENT ON COLUMN "tickets"."users"."created_at" IS 'The timestamp of when the account was created.';
+COMMENT ON COLUMN "tickets"."users"."updated_at" IS 'A timestamp when the account was last changed.';
+
+CREATE TABLE "tickets"."sessions"
+(
+  "id"         VARCHAR(32)              NOT NULL,
+  "uid"        UUID                     NOT NULL,
+  "created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
+  "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL,
+  CONSTRAINT "sessions_pk"     PRIMARY KEY ("id"),
+  CONSTRAINT "sessions_fk_uid" FOREIGN KEY ("uid")
+    REFERENCES "tickets"."users" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE "tickets"."sessions" IS 'Keeps the sessions of users.';
+COMMENT ON COLUMN "tickets"."sessions"."id" IS 'A globally unique session ID.';
+COMMENT ON COLUMN "tickets"."sessions"."uid" IS 'The unique ID of the user account to whom the session belongs.';
+COMMENT ON COLUMN "tickets"."sessions"."created_at" IS 'The timestamp of when the session was created.';
+COMMENT ON COLUMN "tickets"."sessions"."updated_at" IS 'The session ID should be re-generated in regular intervals resulting in a copy of the old session entry with a new ID and the corresponding timestamp in this column.';
+
+CREATE TABLE "tickets"."projects"
+(
+  "id"                 BIGINT                   GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  "name"               CHARACTER VARYING(64)    NOT NULL,
+  "owner"              UUID                     NOT NULL,
+  "is_private"         BOOLEAN                  NOT NULL DEFAULT FALSE,
+  "description"        CHARACTER VARYING(254),
+  "created_at"         TIMESTAMP WITH TIME ZONE NOT NULL,
+  "updated_at"         TIMESTAMP WITH TIME ZONE NOT NULL,
+  "next_ticket_number" INTEGER                  NOT NULL DEFAULT 1,
+  CONSTRAINT "projects_unique_owner_name" UNIQUE ("owner", "name"),
+  CONSTRAINT "projects_fk_uid" FOREIGN KEY ("owner")
+    REFERENCES "tickets"."users" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE "tickets"."projects" IS 'All projects which are basically mirrored repositories from the hub are stored within this table.';
+COMMENT ON COLUMN "tickets"."projects"."id" IS 'An auto generated primary key.';
+COMMENT ON COLUMN "tickets"."projects"."name" IS 'The name of the project. A project 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.';
+COMMENT ON COLUMN "tickets"."projects"."owner" IS 'The unique ID of the user account that owns the project.';
+COMMENT ON COLUMN "tickets"."projects"."is_private" IS 'A flag indicating if this project is private i.e. only visible / accessible for users with appropriate permissions.';
+COMMENT ON COLUMN "tickets"."projects"."description" IS 'An optional short text description of the project.';
+COMMENT ON COLUMN "tickets"."projects"."created_at" IS 'The timestamp of when the project was created.';
+COMMENT ON COLUMN "tickets"."projects"."updated_at" IS 'A timestamp when the project was last changed.';
+COMMENT ON COLUMN "tickets"."projects"."next_ticket_number" IS 'Tickets are numbered ascending per project and this field holds the next logical ticket number to be used and must be incremented upon creation of a new ticket.';
+
+CREATE TABLE "tickets"."labels"
+(
+  "id"          BIGINT                 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  "project"     BIGINT                 NOT NULL,
+  "name"        CHARACTER VARYING(40)  NOT NULL,
+  "description" CHARACTER VARYING(254) DEFAULT NULL,
+  "colour"      CHARACTER VARYING(7)   NOT NULL,
+  CONSTRAINT "labels_unique_project_label" UNIQUE ("project", "name"),
+  CONSTRAINT "labels_fk_project" FOREIGN KEY ("project")
+    REFERENCES "tickets"."projects" ("id") ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE "tickets"."labels" IS 'Labels used to add information to tickets.';
+COMMENT ON COLUMN "tickets"."labels"."id" IS 'An auto generated primary key.';
+COMMENT ON COLUMN "tickets"."labels"."project" IS 'The project to which this label belongs.'; 
+COMMENT ON COLUMN "tickets"."labels"."name" IS 'A short descriptive name for the label which is supposed to be unique in a project context.';
+COMMENT ON COLUMN "tickets"."labels"."description" IS 'An optional description if needed.';
+COMMENT ON COLUMN "tickets"."labels"."colour" IS 'A hexadecimal HTML colour code which can be used to mark the label on a rendered website.';
+
+CREATE TABLE "tickets"."milestones"
+(
+  "id"          BIGINT                 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  "project"     BIGINT                 NOT NULL,
+  "title"       CHARACTER VARYING(64)  NOT NULL,
+  "due_date"    DATE                   DEFAULT NULL,
+  "description" TEXT                   DEFAULT NULL,
+  CONSTRAINT "milestones_unique_project_title" UNIQUE ("project", "title"),
+  CONSTRAINT "milestones_fk_project" FOREIGN KEY ("project")
+    REFERENCES "tickets"."projects" ("id") ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE "tickets"."milestones" IS 'Milestones used to organise tickets';
+COMMENT ON COLUMN "tickets"."milestones"."project" IS 'The project to which this milestone belongs.';
+COMMENT ON COLUMN "tickets"."milestones"."title" IS 'A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within a project context.';
+COMMENT ON COLUMN "tickets"."milestones"."due_date" IS 'An optional date on which the milestone is supposed to be reached.';
+COMMENT ON COLUMN "tickets"."milestones"."description" IS 'An optional longer description of the milestone.';
+
+CREATE TABLE "tickets"."tickets"
+(
+  "id"         BIGINT                   GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  "project"    BIGINT                   NOT NULL,
+  "number"     INT                      NOT NULL,
+  "title"      CHARACTER VARYING(72)    NOT NULL,
+  "content"    TEXT                     DEFAULT NULL,
+  "status"     CHARACTER VARYING(16)    NOT NULL,
+  "submitter"  UUID                     DEFAULT NULL,
+  "created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
+  "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL,
+  CONSTRAINT "tickets_unique_project_ticket" UNIQUE ("project", "number"),
+  CONSTRAINT "tickets_fk_project" FOREIGN KEY ("project")
+    REFERENCES "tickets"."projects" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
+  CONSTRAINT "tickets_fk_submitter" FOREIGN KEY ("submitter")
+    REFERENCES "tickets"."users" ("uid") ON UPDATE CASCADE ON DELETE SET NULL
+)
+WITH (
+  OIDS=FALSE
+);
+
+CREATE INDEX "tickets_status" ON "tickets"."tickets" ("status");
+
+COMMENT ON TABLE "tickets"."tickets" IS 'Information about tickets for projects.';
+COMMENT ON COLUMN "tickets"."tickets"."id" IS 'An auto generated primary key.';
+COMMENT ON COLUMN "tickets"."tickets"."project" IS 'The unique ID of the project which is associated with the ticket.';
+COMMENT ON COLUMN "tickets"."tickets"."number" IS 'The number of the ticket which must be unique within the scope of the project.';
+COMMENT ON COLUMN "tickets"."tickets"."title" IS 'A concise and short description of the ticket which should not exceed 72 characters.';
+COMMENT ON COLUMN "tickets"."tickets"."content" IS 'An optional field to describe the ticket in great detail if needed.';
+COMMENT ON COLUMN "tickets"."tickets"."status" IS 'The current status of the ticket describing its life cycle.';
+COMMENT ON COLUMN "tickets"."tickets"."submitter" IS 'The person who submitted (created) this ticket which is optional because of possible account deletion or other reasons.';
+COMMENT ON COLUMN "tickets"."tickets"."created_at" IS 'The timestamp when the ticket was created / submitted.';
+COMMENT ON COLUMN "tickets"."tickets"."updated_at" IS 'The timestamp when the ticket was last updated. Upon creation the update time equals the creation time.';
+
+CREATE TABLE "tickets"."milestone_tickets"
+(
+  "milestone" BIGINT NOT NULL,
+  "ticket"    BIGINT NOT NULL,
+  CONSTRAINT "milestone_tickets_pk" PRIMARY KEY ("milestone", "ticket"),
+  CONSTRAINT "milestone_tickets_fk_milestone" FOREIGN KEY ("milestone")
+    REFERENCES "tickets"."milestones" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
+  CONSTRAINT "milestone_tickets_fk_ticket" FOREIGN KEY ("ticket")
+    REFERENCES "tickets"."tickets" ("id") ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE "tickets"."milestone_tickets" IS 'This table stores the relation between milestones and their tickets.';
+COMMENT ON COLUMN "tickets"."milestone_tickets"."milestone" IS 'The unique ID of the milestone.';
+COMMENT ON COLUMN "tickets"."milestone_tickets"."ticket" IS 'The unique ID of the ticket that is attached to the milestone.';
+
+CREATE TABLE "tickets"."ticket_assignees"
+(
+  "ticket"     BIGINT NOT NULL,
+  "assignee"   UUID NOT NULL,
+  CONSTRAINT "ticket_assignees_pk" PRIMARY KEY ("ticket", "assignee"),
+  CONSTRAINT "ticket_assignees_fk_ticket" FOREIGN KEY ("ticket")
+    REFERENCES "tickets"."tickets" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
+  CONSTRAINT "ticket_assignees_fk_assignee" FOREIGN KEY ("assignee")
+    REFERENCES "tickets"."users" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE "tickets"."ticket_assignees" IS 'This table stores the relation between tickets and their assignees.';
+COMMENT ON COLUMN "tickets"."ticket_assignees"."ticket" IS 'The unqiue ID of the ticket.';
+COMMENT ON COLUMN "tickets"."ticket_assignees"."assignee" IS 'The unique ID of the user account that is assigned to the ticket.';
+
+CREATE TABLE "tickets"."ticket_labels"
+(
+  "ticket" BIGINT NOT NULL,
+  "label"  BIGINT NOT NULL,
+  CONSTRAINT "ticket_labels_pk" PRIMARY KEY ("ticket", "label"),
+  CONSTRAINT "ticket_labels_fk_ticket" FOREIGN KEY ("ticket")
+    REFERENCES "tickets"."tickets" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
+  CONSTRAINT "ticket_labels_fk_label" FOREIGN KEY ("label")
+    REFERENCES "tickets"."labels" ("id") ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE "tickets"."ticket_labels" IS 'This table stores the relation between tickets and their labels.';
+COMMENT ON COLUMN "tickets"."ticket_labels"."ticket" IS 'The unqiue ID of the ticket.';
+COMMENT ON COLUMN "tickets"."ticket_labels"."label" IS 'The unique ID of the label that is attached to the ticket.';
diff -rN -u old-smederee/modules/tickets/src/main/resources/logback.xml new-smederee/modules/tickets/src/main/resources/logback.xml
--- old-smederee/modules/tickets/src/main/resources/logback.xml	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/resources/logback.xml	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration debug="false">
+  <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
+    <encoder>
+      <pattern>%date %highlight(%-5level) %cyan(%logger{0}) - %msg%n</pattern>
+    </encoder>
+  </appender>
+
+  <appender name="async-console" class="ch.qos.logback.classic.AsyncAppender">
+    <appender-ref ref="console"/>
+    <queueSize>5000</queueSize>
+    <discardingThreshold>0</discardingThreshold>
+  </appender>
+
+  <logger name="de.smederee.tickets" level="${smederee.tickets.loglevel:-INFO}" additivity="false">
+    <appender-ref ref="async-console"/>
+  </logger>
+
+  <root level="INFO">
+    <appender-ref ref="async-console"/>
+  </root>
+</configuration>
diff -rN -u old-smederee/modules/tickets/src/main/resources/reference.conf new-smederee/modules/tickets/src/main/resources/reference.conf
--- old-smederee/modules/tickets/src/main/resources/reference.conf	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/resources/reference.conf	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,107 @@
+###############################################################################
+###     Reference configuration file for the Smederee tickets service.      ###
+###############################################################################
+
+tickets {
+  # Authentication / login settings
+  authentication {
+	enabled = true
+
+	# The name used for the authentication cookie.
+	cookie-name = "sloetel"
+
+	# The secret used for the cookie encryption and validation.
+	# Using the default should produce a warning message on startup.
+	cookie-secret = "CHANGEME"
+
+	# Determines after how many failed login attempts an account gets locked.
+	lock-after = 5
+
+	# Timeouts for the authentication session.
+	timeouts {
+	  # The maximum allowed age an authentication session. This setting will
+	  # affect the invalidation of a session on the server side.
+	  # This timeout MUST be triggered regardless of session activity.
+	  absolute-timeout = 3 days
+
+	  # This timeout defines how long after the last activity a session will
+	  # remain valid.
+	  idle-timeout = 30 minutes
+
+	  # The time after which a session will be renewed (a new session ID will be
+	  # generated).
+	  renewal-timeout = 20 minutes
+	}
+  }
+
+  # Configuration of the CSRF protection middleware.
+  csrf-protection {
+	# The official hostname of the service which will be used for the CSRF
+	# protection.
+	host = ${tickets.service.host}
+
+	# The port number which defaults to the port the service is listening on.
+	# If the service is running behind a reverse proxy on a standard port e.g.
+	# 80 or 443 (http or https) then you MUST set this either to `port = null`
+	# or comment it out!
+	port = ${tickets.service.port}
+
+	# The URL scheme which is used for links and will also determine if cookies
+	# will have the secure flag enabled.
+	# Valid options are:
+	# - http
+	# - https
+	scheme = "http"
+  }
+
+  # Configuration of the database.
+  # Defaults are given except for password and can also be overridden via
+  # environment variables.
+  database {
+	# The class name of the JDBC driver to be used.
+	driver = "org.postgresql.Driver"
+	driver = ${?SMEDEREE_TICKETS_DB_DRIVER}
+	# The JDBC connection URL **without** username and password.
+	url    = "jdbc:postgresql://localhost:5432/smederee"
+	url    = ${?SMEDEREE_TICKETS_DB_URL}
+	# The username (login) needed to authenticate against the database.
+	user   = "smederee_tickets"
+	user   = ${?SMEDEREE_TICKETS_DB_USER}
+	# The password needed to authenticate against the database.
+	pass   = ${?SMEDEREE_TICKETS_DB_PASS}
+  }
+
+  # Settings affecting how the service will communicate several information to
+  # the "outside world" e.g. if it runs behind a reverse proxy.
+  external-url {
+	# The official hostname of the service which will be used for the generation
+	# of links.
+	host = ${tickets.service.host}
+
+	# A possible path prefix that will be prepended to any paths used in link
+	# generation. If no path prefix is used then you MUST either comment it out
+	# or set it to `path = null`!
+	#path = null
+	
+	# The port number which defaults to the port the service is listening on.
+	# If the service is running behind a reverse proxy on a standard port e.g.
+	# 80 or 443 (http or https) then you MUST set this either to `port = null`
+	# or comment it out!
+	port = ${tickets.service.port}
+
+	# The URL scheme which is used for links and will also determine if cookies
+	# will have the secure flag enabled.
+	# Valid options are:
+	# - http
+	# - https
+	scheme = "http"
+  }
+
+  # Generic service configuration.
+  service {
+	# The hostname on which the service shall listen for requests.
+	host = "localhost"
+	# The TCP port number on which the service shall listen for requests.
+	port = 8081
+  }
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,163 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import java.util.UUID
+
+import cats._
+import cats.data._
+import cats.syntax.all._
+
+/** A submitter id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath.
+  */
+opaque type AssigneeId = UUID
+object AssigneeId {
+  val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r
+
+  given Eq[AssigneeId] = Eq.fromUniversalEquals
+
+  /** Create an instance of AssigneeId from the given UUID type.
+    *
+    * @param source
+    *   An instance of type UUID which will be returned as a AssigneeId.
+    * @return
+    *   The appropriate instance of AssigneeId.
+    */
+  def apply(source: UUID): AssigneeId = source
+
+  /** Try to create an instance of AssigneeId from the given UUID.
+    *
+    * @param source
+    *   A UUID that should fulfil the requirements to be converted into a AssigneeId.
+    * @return
+    *   An option to the successfully converted AssigneeId.
+    */
+  def from(source: UUID): Option[AssigneeId] = Option(source)
+
+  /** Try to create an instance of AssigneeId from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a AssigneeId.
+    * @return
+    *   An option to the successfully converted AssigneeId.
+    */
+  def fromString(source: String): Either[String, AssigneeId] =
+    Option(source)
+      .filter(s => Format.matches(s))
+      .flatMap { uuidString =>
+        Either.catchNonFatal(UUID.fromString(uuidString)).toOption
+      }
+      .toRight("Illegal value for AssigneeId!")
+
+  /** Generate a new random user id.
+    *
+    * @return
+    *   A user id which is pseudo randomly generated.
+    */
+  def randomAssigneeId: AssigneeId = UUID.randomUUID
+
+  extension (uid: AssigneeId) {
+    def toUUID: UUID = uid
+  }
+}
+
+/** A submitter name has to obey several restrictions which are similiar to the ones found for Unix usernames. It must
+  * start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and be all
+  * lowercase.
+  */
+opaque type AssigneeName = String
+object AssigneeName {
+  given Eq[AssigneeName] = Eq.fromUniversalEquals
+
+  val isAlphanumeric = "^[a-z][a-z0-9]+$".r
+
+  /** Create an instance of AssigneeName from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a AssigneeName.
+    * @return
+    *   The appropriate instance of AssigneeName.
+    */
+  def apply(source: String): AssigneeName = source
+
+  /** Try to create an instance of AssigneeName from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a AssigneeName.
+    * @return
+    *   An option to the successfully converted AssigneeName.
+    */
+  def from(s: String): Option[AssigneeName] = validate(s).toOption
+
+  /** Validate the given string and return either the validated username or a list of errors.
+    *
+    * @param s
+    *   An arbitrary string which should be a username.
+    * @return
+    *   Either a list of errors or the validated username.
+    */
+  def validate(s: String): ValidatedNec[String, AssigneeName] =
+    Option(s).map(_.trim.nonEmpty) match {
+      case Some(true) =>
+        val input = s.trim
+        val miniumLength =
+          if (input.length >= 2)
+            input.validNec
+          else
+            "AssigneeName too short (min. 2 characters)!".invalidNec
+        val maximumLength =
+          if (input.length < 32)
+            input.validNec
+          else
+            "AssigneeName too long (max. 31 characters)!".invalidNec
+        val alphanumeric =
+          if (isAlphanumeric.matches(input))
+            input.validNec
+          else
+            "AssigneeName must be all lowercase alphanumeric characters and start with a character.".invalidNec
+        (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) =>
+          name
+        }
+      case _ => "AssigneeName must not be empty!".invalidNec
+    }
+}
+
+/** Extractor to retrieve an AssigneeName from a path parameter.
+  */
+object AssigneeNamePathParameter {
+  def unapply(str: String): Option[AssigneeName] =
+    Option(str).flatMap { string =>
+      if (string.startsWith("~"))
+        AssigneeName.from(string.drop(1))
+      else
+        None
+    }
+}
+
+/** The assignee for a ticket i.e. the person supposed to be working on it.
+  *
+  * @param id
+  *   A globally unique ID identifying the assignee.
+  * @param name
+  *   The name associated with the assignee which is supposed to be unique.
+  */
+final case class Assignee(id: AssigneeId, name: AssigneeName)
+
+object Assignee {
+  given Eq[Assignee] = Eq.fromUniversalEquals
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets.config
+
+/** A configuration path describes a path within a configuration file and is used to determine locations of certain
+  * configurations within a combined configuration file.
+  */
+opaque type ConfigurationPath = String
+object ConfigurationPath {
+
+  given Conversion[ConfigurationPath, String] = _.toString
+
+  /** Create an instance of ConfigurationPath from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a ConfigurationPath.
+    * @return
+    *   The appropriate instance of ConfigurationPath.
+    */
+  def apply(source: String): ConfigurationPath = source
+
+  /** Try to create an instance of ConfigurationPath from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a ConfigurationPath.
+    * @return
+    *   An option to the successfully converted ConfigurationPath.
+    */
+  def from(source: String): Option[ConfigurationPath] = Option(source).map(_.trim).filter(_.nonEmpty)
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets.config
+
+import pureconfig._
+
+/** Configuration specifying the database access.
+  *
+  * @param driver
+  *   The class name of the JDBC driver to be used.
+  * @param url
+  *   The JDBC connection URL **without** username and password.
+  * @param user
+  *   The username (login) needed to authenticate against the database.
+  * @param pass
+  *   The password needed to authenticate against the database.
+  */
+final case class DatabaseConfig(driver: String, url: String, user: String, pass: String)
+
+object DatabaseConfig {
+  given ConfigReader[DatabaseConfig] = ConfigReader.forProduct4("driver", "url", "user", "pass")(DatabaseConfig.apply)
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets.config
+
+import cats.effect._
+import cats.syntax.all._
+import org.flywaydb.core.Flyway
+import org.flywaydb.core.api.configuration.FluentConfiguration
+import org.flywaydb.core.api.output.MigrateResult
+
+/** Provide functionality to migrate the database used by the service.
+  */
+final class DatabaseMigrator[F[_]: Sync] {
+
+  /** Apply pending migrations to the database if needed using the underlying Flyway library.
+    *
+    * @param url
+    *   The JDBC connection URL **without** username and password.
+    * @param user
+    *   The username (login) needed to authenticate against the database.
+    * @param pass
+    *   The password needed to authenticate against the database.
+    * @return
+    *   A migrate result object holding information about executed migrations and the schema. See the Java-Doc of Flyway
+    *   for details.
+    */
+  def migrate(url: String, user: String, pass: String): F[MigrateResult] =
+    for {
+      flyway <- Sync[F].delay(DatabaseMigrator.configureFlyway(url, user, pass).load())
+      result <- Sync[F].delay(flyway.migrate())
+    } yield result
+}
+
+object DatabaseMigrator {
+
+  /** Configure an instance of flyway with our defaults and the given credentials for a database connection. The
+    * returned instance must be activated by calling the `.load()` method.
+    *
+    * @param url
+    *   The JDBC connection URL **without** username and password.
+    * @param user
+    *   The username (login) needed to authenticate against the database.
+    * @param pass
+    *   The password needed to authenticate against the database.
+    * @return
+    *   An instance configuration which can be turned into a flyway database migrator by calling the `.load()` method.
+    */
+  def configureFlyway(url: String, user: String, pass: String): FluentConfiguration =
+    Flyway.configure().defaultSchema("tickets").locations("classpath:db/migration/tickets").dataSource(url, user, pass)
+
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets.config
+
+import cats._
+import com.comcast.ip4s._
+import de.smederee.html.ExternalUrlConfiguration
+import org.http4s.Uri
+import pureconfig._
+
+/** Configuration for a CSRF protection middleware.
+  *
+  * @param host
+  *   The hostname which will be expected and must be matched.
+  * @param port
+  *   An optional portnumber which will be expected if set.
+  * @param scheme
+  *   The URL scheme which is either HTTP or HTTPS.
+  */
+final case class CsrfProtectionConfiguration(host: Host, port: Option[Port], scheme: Uri.Scheme)
+
+object CsrfProtectionConfiguration {
+  given Eq[CsrfProtectionConfiguration] = Eq.fromUniversalEquals
+
+  given ConfigReader[Host]       = ConfigReader.fromStringOpt[Host](Host.fromString)
+  given ConfigReader[Port]       = ConfigReader.fromStringOpt[Port](Port.fromString)
+  given ConfigReader[Uri.Scheme] = ConfigReader.fromStringOpt(s => Uri.Scheme.fromString(s).toOption)
+
+  given ConfigReader[CsrfProtectionConfiguration] =
+    ConfigReader.forProduct3("host", "port", "scheme")(CsrfProtectionConfiguration.apply)
+}
+
+/** Wrapper class for the confiuration of the Smederee tickets module.
+  *
+  * @param csrfProtection
+  *   The CSRF protection configuration.
+  * @param database
+  *   The configuration needed to access the database.
+  * @param externalUrl
+  *   Configuration regarding support for generating "external urls" which is usually needed if the service runs behind
+  *   a reverse proxy.
+  */
+final case class SmedereeTicketsConfiguration(
+    csrfProtection: CsrfProtectionConfiguration,
+    database: DatabaseConfig,
+    externalUrl: ExternalUrlConfiguration
+)
+
+object SmedereeTicketsConfiguration {
+  val location: ConfigurationPath = ConfigurationPath("tickets")
+
+  given ConfigReader[Host]       = ConfigReader.fromStringOpt[Host](Host.fromString)
+  given ConfigReader[Port]       = ConfigReader.fromStringOpt[Port](Port.fromString)
+  given ConfigReader[Uri]        = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption)
+  given ConfigReader[Uri.Scheme] = ConfigReader.fromStringOpt(s => Uri.Scheme.fromString(s).toOption)
+
+  given ConfigReader[ExternalUrlConfiguration] =
+    ConfigReader.forProduct4("host", "path", "port", "scheme")(ExternalUrlConfiguration.apply)
+
+  given ConfigReader[SmedereeTicketsConfiguration] =
+    ConfigReader.forProduct3("csrf-protection", "database", "external-url")(SmedereeTicketsConfiguration.apply)
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import cats.effect._
+import doobie._
+import doobie.implicits._
+import fs2.Stream
+
+final class DoobieLabelRepository[F[_]: Sync](tx: Transactor[F]) extends LabelRepository[F] {
+  given Meta[ColourCode]       = Meta[String].timap(ColourCode.apply)(_.toString)
+  given Meta[LabelDescription] = Meta[String].timap(LabelDescription.apply)(_.toString)
+  given Meta[LabelId]          = Meta[Long].timap(LabelId.apply)(_.toLong)
+  given Meta[LabelName]        = Meta[String].timap(LabelName.apply)(_.toString)
+
+  override def allLabels(vcsRepositoryId: Long): Stream[F, Label] =
+    sql"""SELECT id, name, description, colour FROM "tickets"."labels" WHERE project = $vcsRepositoryId ORDER BY name ASC"""
+      .query[Label]
+      .stream
+      .transact(tx)
+
+  override def createLabel(vcsRepositoryId: Long)(label: Label): F[Int] =
+    sql"""INSERT INTO "tickets"."labels"
+          (
+            project,
+            name,
+            description,
+            colour
+          )
+          VALUES (
+            $vcsRepositoryId,
+            ${label.name},
+            ${label.description},
+            ${label.colour}
+          )""".update.run.transact(tx)
+
+  override def deleteLabel(label: Label): F[Int] =
+    label.id match {
+      case None => Sync[F].pure(0)
+      case Some(id) =>
+        sql"""DELETE FROM "tickets"."labels" WHERE id = $id""".update.run.transact(tx)
+    }
+
+  override def findLabel(vcsRepositoryId: Long)(name: LabelName): F[Option[Label]] =
+    sql"""SELECT id, name, description, colour FROM "tickets"."labels" WHERE project = $vcsRepositoryId AND name = $name LIMIT 1"""
+      .query[Label]
+      .option
+      .transact(tx)
+
+  override def updateLabel(label: Label): F[Int] =
+    label.id match {
+      case None => Sync[F].pure(0)
+      case Some(id) =>
+        sql"""UPDATE "tickets"."labels" 
+              SET name = ${label.name},
+              description = ${label.description},
+              colour = ${label.colour}
+              WHERE id = $id""".update.run.transact(tx)
+    }
+
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import cats.effect._
+import doobie._
+import doobie.implicits._
+import doobie.postgres.implicits._
+import fs2.Stream
+
+final class DoobieMilestoneRepository[F[_]: Sync](tx: Transactor[F]) extends MilestoneRepository[F] {
+  given Meta[MilestoneDescription] = Meta[String].timap(MilestoneDescription.apply)(_.toString)
+  given Meta[MilestoneId]          = Meta[Long].timap(MilestoneId.apply)(_.toLong)
+  given Meta[MilestoneTitle]       = Meta[String].timap(MilestoneTitle.apply)(_.toString)
+
+  override def allMilestones(projectId: Long): Stream[F, Milestone] =
+    sql"""SELECT id, title, description, due_date FROM "tickets"."milestones" WHERE project = $projectId ORDER BY due_date ASC, title ASC"""
+      .query[Milestone]
+      .stream
+      .transact(tx)
+
+  override def createMilestone(projectId: Long)(milestone: Milestone): F[Int] =
+    sql"""INSERT INTO "tickets"."milestones"
+          (
+            project,
+            title,
+            due_date,
+            description
+          )
+          VALUES (
+            $projectId,
+            ${milestone.title},
+            ${milestone.dueDate},
+            ${milestone.description}
+          )""".update.run.transact(tx)
+
+  override def deleteMilestone(milestone: Milestone): F[Int] =
+    milestone.id match {
+      case None     => Sync[F].pure(0)
+      case Some(id) => sql"""DELETE FROM "tickets"."milestones" WHERE id = $id""".update.run.transact(tx)
+    }
+
+  override def findMilestone(projectId: Long)(title: MilestoneTitle): F[Option[Milestone]] =
+    sql"""SELECT id, title, description, due_date FROM "tickets"."milestones" WHERE project = $projectId AND title = $title LIMIT 1"""
+      .query[Milestone]
+      .option
+      .transact(tx)
+
+  override def updateMilestone(milestone: Milestone): F[Int] =
+    milestone.id match {
+      case None => Sync[F].pure(0)
+      case Some(id) =>
+        sql"""UPDATE "tickets"."milestones"
+              SET title = ${milestone.title},
+                due_date = ${milestone.dueDate},
+                description = ${milestone.description}
+              WHERE id = $id""".update.run.transact(tx)
+    }
+
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets.forms
+
+import cats.data._
+import de.smederee.tickets.forms.types._
+
+/** A base class for form validators.
+  *
+  * <p>It is intended to extend this class if you want to provide a more sophisticated validation for a form which gets
+  * submitted as raw stringified map.</p>
+  *
+  * <p>Please note that you can achieve auto validation if you use proper models (with refined types) in your tapir
+  * endpoints.</p>
+  *
+  * <p>However, sometimes you want to have more fine grained control...</p>
+  *
+  * @tparam T
+  *   The concrete type of the validated form output.
+  */
+abstract class FormValidator[T] {
+  final val fieldGlobal: FormField = FormValidator.fieldGlobal
+
+  /** Validate the submitted form data which is received as stringified map and return either a validated `T` or a list
+    * of [[de.smederee.tickets.forms.types.FormErrors]].
+    *
+    * @param data
+    *   The stringified map which was submitted.
+    * @return
+    *   Either the validated form as concrete type T or a list of form errors.
+    */
+  def validate(data: Map[String, String]): ValidatedNec[FormErrors, T]
+
+}
+
+object FormValidator {
+  // A constant for the field name used for global errors.
+  val fieldGlobal: FormField = FormField("global")
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets.forms
+
+import cats.data._
+import cats.syntax.all._
+
+object types {
+
+  type FormErrors = Map[FormField, List[FormFieldError]]
+  object FormErrors {
+    val empty: FormErrors = Map.empty[FormField, List[FormFieldError]]
+
+    /** Create a single FormErrors instance from a given non empty chain of FormErrors which is usually returned from
+      * validators.
+      *
+      * @param errors
+      *   A non empty chain of FormErrors.
+      * @return
+      *   A single FormErrors instance containing all the errors.
+      */
+    def fromNec(errors: NonEmptyChain[FormErrors]): FormErrors = errors.toList.fold(FormErrors.empty)(_ combine _)
+
+    /** Create a single FormErrors instance from a given non empty list of FormErrors which is usually returned from
+      * validators.
+      *
+      * @param errors
+      *   A non empty list of FormErrors.
+      * @return
+      *   A single FormErrors instance containing all the errors.
+      */
+    def fromNel(errors: NonEmptyList[FormErrors]): FormErrors = errors.toList.fold(FormErrors.empty)(_ combine _)
+  }
+
+  opaque type FormField = String
+  object FormField {
+
+    /** Create an instance of FormField from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a FormField.
+      * @return
+      *   The appropriate instance of FormField.
+      */
+    def apply(source: String): FormField = source
+
+    /** Try to create an instance of FormField from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a FormField.
+      * @return
+      *   An option to the successfully converted FormField.
+      */
+    def from(source: String): Option[FormField] =
+      Option(source).map(_.trim.nonEmpty) match {
+        case Some(true) => Option(source.trim)
+        case _          => None
+      }
+  }
+
+  given Conversion[FormField, String] = _.toString
+
+  opaque type FormFieldError = String
+  object FormFieldError {
+
+    /** Create an instance of FormFieldError from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a FormFieldError.
+      * @return
+      *   The appropriate instance of FormFieldError.
+      */
+    def apply(source: String): FormFieldError = source
+
+    /** Try to create an instance of FormFieldError from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a FormFieldError.
+      * @return
+      *   An option to the successfully converted FormFieldError.
+      */
+    def from(source: String): Option[FormFieldError] =
+      Option(source).map(_.trim.nonEmpty) match {
+        case Some(true) => Option(source.trim)
+        case _          => None
+      }
+  }
+
+  given Conversion[FormFieldError, String] = _.toString
+
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import fs2.Stream
+
+/** The base class that defines the needed functionality to handle labels within a database.
+  *
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
+  */
+abstract class LabelRepository[F[_]] {
+
+  /** Return all labels associated with the given repository.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry for which all labels shall be returned.
+    * @return
+    *   A stream of labels associated with the vcs repository which may be empty.
+    */
+  def allLabels(vcsRepositoryId: Long): Stream[F, Label]
+
+  /** Create a database entry for the given label definition.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry to which the label belongs.
+    * @param label
+    *   The label definition that shall be written to the database.
+    * @return
+    *   The number of affected database rows.
+    */
+  def createLabel(vcsRepositoryId: Long)(label: Label): F[Int]
+
+  /** Delete the label from the database.
+    *
+    * @param label
+    *   The label definition that shall be deleted from the database.
+    * @return
+    *   The number of affected database rows.
+    */
+  def deleteLabel(label: Label): F[Int]
+
+  /** Find the label with the given name for the given vcs repository.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry to which the label belongs.
+    * @param name
+    *   The name of the label which is must be unique in the context of the repository.
+    * @return
+    *   An option to the found label.
+    */
+  def findLabel(vcsRepositoryId: Long)(name: LabelName): F[Option[Label]]
+
+  /** Update the database entry for the given label.
+    *
+    * @param label
+    *   The label definition that shall be updated within the database.
+    * @return
+    *   The number of affected database rows.
+    */
+  def updateLabel(label: Label): F[Int]
+
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,186 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import cats._
+import cats.syntax.all._
+
+import scala.util.matching.Regex
+
+opaque type LabelId = Long
+object LabelId {
+  given Eq[LabelId] = Eq.fromUniversalEquals
+
+  val Format: Regex = "^-?\\d+$".r
+
+  /** Create an instance of LabelId from the given Long type.
+    *
+    * @param source
+    *   An instance of type Long which will be returned as a LabelId.
+    * @return
+    *   The appropriate instance of LabelId.
+    */
+  def apply(source: Long): LabelId = source
+
+  /** Try to create an instance of LabelId from the given Long.
+    *
+    * @param source
+    *   A Long that should fulfil the requirements to be converted into a LabelId.
+    * @return
+    *   An option to the successfully converted LabelId.
+    */
+  def from(source: Long): Option[LabelId] = Option(source)
+
+  /** Try to create an instance of LabelId from the given String.
+    *
+    * @param source
+    *   A string that should fulfil the requirements to be converted into a LabelId.
+    * @return
+    *   An option to the successfully converted LabelId.
+    */
+  def fromString(source: String): Option[LabelId] = Option(source).filter(Format.matches).map(_.toLong).flatMap(from)
+
+  extension (id: LabelId) {
+    def toLong: Long = id
+  }
+
+}
+
+/** Extractor to retrieve an LabelId from a path parameter.
+  */
+object LabelIdPathParameter {
+  def unapply(str: String): Option[LabelId] = Option(str).flatMap(LabelId.fromString)
+}
+
+/** A short descriptive name for the label which is supposed to be unique in a project context. It must not be empty and
+  * not exceed 40 characters in length.
+  */
+opaque type LabelName = String
+object LabelName {
+  given Eq[LabelName]       = Eq.fromUniversalEquals
+  given Ordering[LabelName] = (x: LabelName, y: LabelName) => x.compareTo(y)
+  given Order[LabelName]    = Order.fromOrdering[LabelName]
+
+  val MaxLength: Int = 40
+
+  /** Create an instance of LabelName from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a LabelName.
+    * @return
+    *   The appropriate instance of LabelName.
+    */
+  def apply(source: String): LabelName = source
+
+  /** Try to create an instance of LabelName from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a LabelName.
+    * @return
+    *   An option to the successfully converted LabelName.
+    */
+  def from(source: String): Option[LabelName] =
+    Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
+
+}
+
+/** Extractor to retrieve an LabelName from a path parameter.
+  */
+object LabelNamePathParameter {
+  def unapply(str: String): Option[LabelName] = Option(str).flatMap(LabelName.from)
+}
+
+/** A maybe needed description of a label which must not be empty and not exceed 254 characters in length.
+  */
+opaque type LabelDescription = String
+object LabelDescription {
+  given Eq[LabelDescription] = Eq.fromUniversalEquals
+
+  val MaxLength: Int = 254
+
+  /** Create an instance of LabelDescription from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a LabelDescription.
+    * @return
+    *   The appropriate instance of LabelDescription.
+    */
+  def apply(source: String): LabelDescription = source
+
+  /** Try to create an instance of LabelDescription from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a LabelDescription.
+    * @return
+    *   An option to the successfully converted LabelDescription.
+    */
+  def from(source: String): Option[LabelDescription] =
+    Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
+}
+
+/** An HTML colour code in hexadecimal notation e.g. `#12ab3f`. It must match the format of starting with a hashtag
+  * followed by three 2-digit hexadecimal codes (`00-ff`).
+  */
+opaque type ColourCode = String
+object ColourCode {
+  given Eq[ColourCode] = Eq.instance((a, b) => a.equalsIgnoreCase(b))
+
+  val Format: Regex = "^#[0-9a-fA-F]{6}$".r
+
+  /** Create an instance of ColourCode from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a ColourCode.
+    * @return
+    *   The appropriate instance of ColourCode.
+    */
+  def apply(source: String): ColourCode = source
+
+  /** Try to create an instance of ColourCode from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a ColourCode.
+    * @return
+    *   An option to the successfully converted ColourCode.
+    */
+  def from(source: String): Option[ColourCode] = Option(source).filter(string => Format.matches(string))
+
+}
+
+/** A label is intended to mark tickets with keywords and colours to allow filtering on them.
+  *
+  * @param id
+  *   An optional attribute containing the unique internal database ID for the label.
+  * @param name
+  *   A short descriptive name for the label which is supposed to be unique in a project context.
+  * @param description
+  *   An optional description if needed.
+  * @param colour
+  *   A hexadecimal HTML colour code which can be used to mark the label on a rendered website.
+  */
+final case class Label(id: Option[LabelId], name: LabelName, description: Option[LabelDescription], colour: ColourCode)
+
+object Label {
+  given Eq[Label] =
+    Eq.instance((thisLabel, thatLabel) =>
+      thisLabel.id === thatLabel.id &&
+        thisLabel.name === thatLabel.name &&
+        thisLabel.description === thatLabel.description &&
+        thisLabel.colour === thatLabel.colour
+    )
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import fs2.Stream
+
+/** The base class that defines the needed functionality to handle milestones within a database.
+  *
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
+  */
+abstract class MilestoneRepository[F[_]] {
+
+  /** Return all milestones associated with the given repository.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry for which all milestones shall be returned.
+    * @return
+    *   A stream of milestones associated with the vcs repository which may be empty.
+    */
+  def allMilestones(vcsRepositoryId: Long): Stream[F, Milestone]
+
+  /** Create a database entry for the given milestone definition.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry to which the milestone belongs.
+    * @param milestone
+    *   The milestone definition that shall be written to the database.
+    * @return
+    *   The number of affected database rows.
+    */
+  def createMilestone(vcsRepositoryId: Long)(milestone: Milestone): F[Int]
+
+  /** Delete the milestone from the database.
+    *
+    * @param milestone
+    *   The milestone definition that shall be deleted from the database.
+    * @return
+    *   The number of affected database rows.
+    */
+  def deleteMilestone(milestone: Milestone): F[Int]
+
+  /** Find the milestone with the given title for the given vcs repository.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry to which the milestone belongs.
+    * @param title
+    *   The title of the milestone which is must be unique in the context of the repository.
+    * @return
+    *   An option to the found milestone.
+    */
+  def findMilestone(vcsRepositoryId: Long)(title: MilestoneTitle): F[Option[Milestone]]
+
+  /** Update the database entry for the given milestone.
+    *
+    * @param milestone
+    *   The milestone definition that shall be updated within the database.
+    * @return
+    *   The number of affected database rows.
+    */
+  def updateMilestone(milestone: Milestone): F[Int]
+
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import java.time.LocalDate
+
+import cats._
+import cats.syntax.all._
+
+import scala.util.matching.Regex
+
+opaque type MilestoneId = Long
+object MilestoneId {
+  given Eq[MilestoneId] = Eq.fromUniversalEquals
+
+  val Format: Regex = "^-?\\d+$".r
+
+  /** Create an instance of MilestoneId from the given Long type.
+    *
+    * @param source
+    *   An instance of type Long which will be returned as a MilestoneId.
+    * @return
+    *   The appropriate instance of MilestoneId.
+    */
+  def apply(source: Long): MilestoneId = source
+
+  /** Try to create an instance of MilestoneId from the given Long.
+    *
+    * @param source
+    *   A Long that should fulfil the requirements to be converted into a MilestoneId.
+    * @return
+    *   An option to the successfully converted MilestoneId.
+    */
+  def from(source: Long): Option[MilestoneId] = Option(source)
+
+  /** Try to create an instance of MilestoneId from the given String.
+    *
+    * @param source
+    *   A string that should fulfil the requirements to be converted into a MilestoneId.
+    * @return
+    *   An option to the successfully converted MilestoneId.
+    */
+  def fromString(source: String): Option[MilestoneId] =
+    Option(source).filter(Format.matches).map(_.toLong).flatMap(from)
+
+  extension (id: MilestoneId) {
+    def toLong: Long = id
+  }
+}
+
+/** Extractor to retrieve an MilestoneId from a path parameter.
+  */
+object MilestoneIdPathParameter {
+  def unapply(str: String): Option[MilestoneId] = Option(str).flatMap(MilestoneId.fromString)
+}
+
+/** A title for a milestone, usually a version number, a word or a short phrase that is supposed to be unique within a
+  * project context. It must not be empty and not exceed 64 characters in length.
+  */
+opaque type MilestoneTitle = String
+object MilestoneTitle {
+  given Eq[MilestoneTitle]       = Eq.fromUniversalEquals
+  given Ordering[MilestoneTitle] = (x: MilestoneTitle, y: MilestoneTitle) => x.compareTo(y)
+  given Order[MilestoneTitle]    = Order.fromOrdering[MilestoneTitle]
+
+  val MaxLength: Int = 64
+
+  /** Create an instance of MilestoneTitle from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a MilestoneTitle.
+    * @return
+    *   The appropriate instance of MilestoneTitle.
+    */
+  def apply(source: String): MilestoneTitle = source
+
+  /** Try to create an instance of MilestoneTitle from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a MilestoneTitle.
+    * @return
+    *   An option to the successfully converted MilestoneTitle.
+    */
+  def from(source: String): Option[MilestoneTitle] =
+    Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
+
+}
+
+/** Extractor to retrieve an MilestoneTitle from a path parameter.
+  */
+object MilestoneTitlePathParameter {
+  def unapply(str: String): Option[MilestoneTitle] = Option(str).flatMap(MilestoneTitle.from)
+}
+
+/** A longer detailed description of a project milestone which must not be empty.
+  */
+opaque type MilestoneDescription = String
+object MilestoneDescription {
+  given Eq[MilestoneDescription] = Eq.fromUniversalEquals
+
+  /** Create an instance of MilestoneDescription from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a MilestoneDescription.
+    * @return
+    *   The appropriate instance of MilestoneDescription.
+    */
+  def apply(source: String): MilestoneDescription = source
+
+  /** Try to create an instance of MilestoneDescription from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a MilestoneDescription.
+    * @return
+    *   An option to the successfully converted MilestoneDescription.
+    */
+  def from(source: String): Option[MilestoneDescription] = Option(source).map(_.trim).filter(_.nonEmpty)
+
+}
+
+/** A milestone can be used to organise tickets and progress inside a project.
+  *
+  * @param id
+  *   An optional attribute containing the unique internal database ID for the milestone.
+  * @param title
+  *   A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within
+  *   a project context.
+  * @param description
+  *   An optional longer description of the milestone.
+  * @param dueDate
+  *   An optional date on which the milestone is supposed to be reached.
+  */
+final case class Milestone(
+    id: Option[MilestoneId],
+    title: MilestoneTitle,
+    description: Option[MilestoneDescription],
+    dueDate: Option[LocalDate]
+)
+
+object Milestone {
+
+  given Eq[LocalDate] = Eq.instance((a, b) => a.compareTo(b) === 0)
+
+  given Eq[Milestone] =
+    Eq.instance((a, b) =>
+      a.id === b.id && a.title === b.title && a.dueDate === b.dueDate && a.description === b.description
+    )
+
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+/** A base class for a database repository that should handle all functionality regarding projects in the database.
+  *
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
+  */
+abstract class ProjectRepository[F[_]] {
+
+  /** Search for the project entry with the given owner and name.
+    *
+    * @param owner
+    *   Data about the owner of the project containing information needed to query the database.
+    * @param name
+    *   The project name which must be unique in regard to the owner.
+    * @return
+    *   An option to the successfully found project entry.
+    */
+  def findProject(owner: ProjectOwner, name: ProjectName): F[Option[Project]]
+
+  /** Search for the internal database specific (auto generated) ID of the given owner / project combination which
+    * serves as a primary key for the database table.
+    *
+    * @param owner
+    *   Data about the owner of the project containing information needed to query the database.
+    * @param name
+    *   The project name which must be unique in regard to the owner.
+    * @return
+    *   An option to the internal database ID.
+    */
+  def findProjectId(owner: ProjectOwner, name: ProjectName): F[Option[Long]]
+
+  /** Search for a project owner of whom we only know the name.
+    *
+    * @param name
+    *   The name of the project owner which is the username of the actual owners account.
+    * @return
+    *   An option to successfully found project owner.
+    */
+  def findProjectOwner(name: ProjectOwnerName): F[Option[ProjectOwner]]
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,314 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import java.util.UUID
+
+import cats._
+import cats.data._
+import cats.syntax.all._
+import de.smederee.email.EmailAddress
+import de.smederee.security.{ UserId, Username }
+
+import scala.util.matching.Regex
+
+opaque type ProjectDescription = String
+object ProjectDescription {
+  val MaximumLength: Int = 8192
+
+  /** Create an instance of ProjectDescription from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a ProjectDescription.
+    * @return
+    *   The appropriate instance of ProjectDescription.
+    */
+  def apply(source: String): ProjectDescription = source
+
+  /** Try to create an instance of ProjectDescription from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a ProjectDescription.
+    * @return
+    *   An option to the successfully converted ProjectDescription.
+    */
+  def from(source: String): Option[ProjectDescription] = Option(source).map(_.take(MaximumLength))
+
+}
+
+opaque type ProjectName = String
+object ProjectName {
+
+  given Eq[ProjectName] = Eq.fromUniversalEquals
+
+  given Order[ProjectName] = Order.from((a, b) => a.toString.compareTo(b.toString))
+
+  // TODO Can we rewrite this in a Scala-3 way (i.e. without implicitly)?
+  given Ordering[ProjectName] = implicitly[Order[ProjectName]].toOrdering
+
+  val Format: Regex = "^[a-zA-Z0-9][a-zA-Z0-9\\-_]{1,63}$".r
+
+  /** Create an instance of ProjectName from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a ProjectName.
+    * @return
+    *   The appropriate instance of ProjectName.
+    */
+  def apply(source: String): ProjectName = source
+
+  /** Try to create an instance of ProjectName from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a ProjectName.
+    * @return
+    *   An option to the successfully converted ProjectName.
+    */
+  def from(source: String): Option[ProjectName] = validate(source).toOption
+
+  /** Validate the given string and return either the validated repository name or a list of errors.
+    *
+    * @param s
+    *   An arbitrary string which should be a repository name.
+    * @return
+    *   Either a list of errors or the validated repository name.
+    */
+  def validate(s: String): ValidatedNec[String, ProjectName] =
+    Option(s).map(_.trim.nonEmpty) match {
+      case Some(true) =>
+        val input = s.trim
+        val miniumLength =
+          if (input.length > 1)
+            input.validNec
+          else
+            "Repository name too short (min. 2 characters)!".invalidNec
+        val maximumLength =
+          if (input.length < 65)
+            input.validNec
+          else
+            "Repository name too long (max. 64 characters)!".invalidNec
+        val validFormat =
+          if (Format.matches(input))
+            input.validNec
+          else
+            "Repository name must start with a letter or number and is only allowed to contain alphanumeric characters plus .".invalidNec
+        (miniumLength, maximumLength, validFormat).mapN { case (_, _, name) =>
+          name
+        }
+      case _ => "Repository name must not be empty!".invalidNec
+    }
+}
+
+/** Extractor to retrieve a ProjectName from a path parameter.
+  */
+object ProjectNamePathParameter {
+  def unapply(str: String): Option[ProjectName] = Option(str).flatMap(ProjectName.from)
+}
+
+/** A project owner id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath.
+  */
+opaque type ProjectOwnerId = UUID
+object ProjectOwnerId {
+  val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r
+
+  given Eq[ProjectOwnerId] = Eq.fromUniversalEquals
+
+  given Conversion[UserId, ProjectOwnerId] = ProjectOwnerId.fromUserId
+
+  /** Create an instance of ProjectOwnerId from the given UUID type.
+    *
+    * @param source
+    *   An instance of type UUID which will be returned as a ProjectOwnerId.
+    * @return
+    *   The appropriate instance of ProjectOwnerId.
+    */
+  def apply(source: UUID): ProjectOwnerId = source
+
+  /** Try to create an instance of ProjectOwnerId from the given UUID.
+    *
+    * @param source
+    *   A UUID that should fulfil the requirements to be converted into a ProjectOwnerId.
+    * @return
+    *   An option to the successfully converted ProjectOwnerId.
+    */
+  def from(source: UUID): Option[ProjectOwnerId] = Option(source)
+
+  /** Try to create an instance of ProjectOwnerId from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a ProjectOwnerId.
+    * @return
+    *   An option to the successfully converted ProjectOwnerId.
+    */
+  def fromString(source: String): Either[String, ProjectOwnerId] =
+    Option(source)
+      .filter(s => Format.matches(s))
+      .flatMap { uuidString =>
+        Either.catchNonFatal(UUID.fromString(uuidString)).toOption
+      }
+      .toRight("Illegal value for ProjectOwnerId!")
+
+  /** Create an instance of ProjectOwnerId from the given UserId type.
+    *
+    * @param uid
+    *   An instance of type UserId which will be returned as a ProjectOwnerId.
+    * @return
+    *   The appropriate instance of ProjectOwnerId.
+    */
+  def fromUserId(uid: UserId): ProjectOwnerId = uid.toUUID
+
+  /** Generate a new random project owner id.
+    *
+    * @return
+    *   A project owner id which is pseudo randomly generated.
+    */
+  def randomProjectOwnerId: ProjectOwnerId = UUID.randomUUID
+
+  extension (uid: ProjectOwnerId) {
+    def toUUID: UUID = uid
+  }
+}
+
+/** A project owner name for an account has to obey several restrictions which are similiar to the ones found for Unix
+  * usernames. It must start with a letter, contain only alphanumeric characters, be at least 2 and at most 31
+  * characters long and be all lowercase.
+  */
+opaque type ProjectOwnerName = String
+object ProjectOwnerName {
+  given Eq[ProjectOwnerName] = Eq.fromUniversalEquals
+
+  given Conversion[Username, ProjectOwnerName] = ProjectOwnerName.fromUsername
+
+  val isAlphanumeric = "^[a-z][a-z0-9]+$".r
+
+  /** Create an instance of ProjectOwnerName from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a ProjectOwnerName.
+    * @return
+    *   The appropriate instance of ProjectOwnerName.
+    */
+  def apply(source: String): ProjectOwnerName = source
+
+  /** Try to create an instance of ProjectOwnerName from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a ProjectOwnerName.
+    * @return
+    *   An option to the successfully converted ProjectOwnerName.
+    */
+  def from(s: String): Option[ProjectOwnerName] = validate(s).toOption
+
+  /** Create an instance of ProjectOwnerName from the given Username type.
+    *
+    * @param username
+    *   An instance of the type Username which will be returned as a ProjectOwnerName.
+    * @return
+    *   The appropriate instance of ProjectOwnerName.
+    */
+  def fromUsername(username: Username): ProjectOwnerName = username.toString
+
+  /** Validate the given string and return either the validated project owner name or a list of errors.
+    *
+    * @param s
+    *   An arbitrary string which should be a project owner name.
+    * @return
+    *   Either a list of errors or the validated project owner name.
+    */
+  def validate(s: String): ValidatedNec[String, ProjectOwnerName] =
+    Option(s).map(_.trim.nonEmpty) match {
+      case Some(true) =>
+        val input = s.trim
+        val miniumLength =
+          if (input.length >= 2)
+            input.validNec
+          else
+            "ProjectOwnerName too short (min. 2 characters)!".invalidNec
+        val maximumLength =
+          if (input.length < 32)
+            input.validNec
+          else
+            "ProjectOwnerName too long (max. 31 characters)!".invalidNec
+        val alphanumeric =
+          if (isAlphanumeric.matches(input))
+            input.validNec
+          else
+            "ProjectOwnerName must be all lowercase alphanumeric characters and start with a character.".invalidNec
+        (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) =>
+          name
+        }
+      case _ => "ProjectOwnerName must not be empty!".invalidNec
+    }
+
+  extension (ownername: ProjectOwnerName) {
+
+    /** Convert this project owner name into a username.
+      *
+      * @return
+      *   A syntactically valid username.
+      */
+    def toUsername: Username = Username(ownername.toString)
+  }
+}
+
+/** Extractor to retrieve an ProjectOwnerName from a path parameter.
+  */
+object ProjectOwnerNamePathParameter {
+  def unapply(str: String): Option[ProjectOwnerName] =
+    Option(str).flatMap { string =>
+      if (string.startsWith("~"))
+        ProjectOwnerName.from(string.drop(1))
+      else
+        None
+    }
+}
+
+/** Descriptive information about the owner of a project.
+  *
+  * @param owner
+  *   The unique ID of the project owner.
+  * @param name
+  *   The name of the project owner which is supposed to be unique.
+  * @param email
+  *   The email address of the project owner.
+  */
+final case class ProjectOwner(uid: ProjectOwnerId, name: ProjectOwnerName, email: EmailAddress)
+
+object ProjectOwner {
+  given Eq[ProjectOwner] = Eq.fromUniversalEquals
+}
+
+/** A project is the base entity for tracking tickets.
+  *
+  * @param owner
+  *   The owner of the project.
+  * @param name
+  *   The name of the project. A project 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.
+  * @param description
+  *   An optional short text description of the project.
+  * @param isPrivate
+  *   A flag indicating if this project is private i.e. only visible / accessible for accounts with appropriate
+  *   permissions.
+  */
+final case class Project(
+    owner: ProjectOwner,
+    name: ProjectName,
+    description: Option[ProjectDescription],
+    isPrivate: Boolean
+)
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,163 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import java.util.UUID
+
+import cats._
+import cats.data._
+import cats.syntax.all._
+
+/** A submitter id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath.
+  */
+opaque type SubmitterId = UUID
+object SubmitterId {
+  val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r
+
+  given Eq[SubmitterId] = Eq.fromUniversalEquals
+
+  /** Create an instance of SubmitterId from the given UUID type.
+    *
+    * @param source
+    *   An instance of type UUID which will be returned as a SubmitterId.
+    * @return
+    *   The appropriate instance of SubmitterId.
+    */
+  def apply(source: UUID): SubmitterId = source
+
+  /** Try to create an instance of SubmitterId from the given UUID.
+    *
+    * @param source
+    *   A UUID that should fulfil the requirements to be converted into a SubmitterId.
+    * @return
+    *   An option to the successfully converted SubmitterId.
+    */
+  def from(source: UUID): Option[SubmitterId] = Option(source)
+
+  /** Try to create an instance of SubmitterId from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a SubmitterId.
+    * @return
+    *   An option to the successfully converted SubmitterId.
+    */
+  def fromString(source: String): Either[String, SubmitterId] =
+    Option(source)
+      .filter(s => Format.matches(s))
+      .flatMap { uuidString =>
+        Either.catchNonFatal(UUID.fromString(uuidString)).toOption
+      }
+      .toRight("Illegal value for SubmitterId!")
+
+  /** Generate a new random user id.
+    *
+    * @return
+    *   A user id which is pseudo randomly generated.
+    */
+  def randomSubmitterId: SubmitterId = UUID.randomUUID
+
+  extension (uid: SubmitterId) {
+    def toUUID: UUID = uid
+  }
+}
+
+/** A submitter name has to obey several restrictions which are similiar to the ones found for Unix usernames. It must
+  * start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and be all
+  * lowercase.
+  */
+opaque type SubmitterName = String
+object SubmitterName {
+  given Eq[SubmitterName] = Eq.fromUniversalEquals
+
+  val isAlphanumeric = "^[a-z][a-z0-9]+$".r
+
+  /** Create an instance of SubmitterName from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a SubmitterName.
+    * @return
+    *   The appropriate instance of SubmitterName.
+    */
+  def apply(source: String): SubmitterName = source
+
+  /** Try to create an instance of SubmitterName from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a SubmitterName.
+    * @return
+    *   An option to the successfully converted SubmitterName.
+    */
+  def from(s: String): Option[SubmitterName] = validate(s).toOption
+
+  /** Validate the given string and return either the validated username or a list of errors.
+    *
+    * @param s
+    *   An arbitrary string which should be a username.
+    * @return
+    *   Either a list of errors or the validated username.
+    */
+  def validate(s: String): ValidatedNec[String, SubmitterName] =
+    Option(s).map(_.trim.nonEmpty) match {
+      case Some(true) =>
+        val input = s.trim
+        val miniumLength =
+          if (input.length >= 2)
+            input.validNec
+          else
+            "SubmitterName too short (min. 2 characters)!".invalidNec
+        val maximumLength =
+          if (input.length < 32)
+            input.validNec
+          else
+            "SubmitterName too long (max. 31 characters)!".invalidNec
+        val alphanumeric =
+          if (isAlphanumeric.matches(input))
+            input.validNec
+          else
+            "SubmitterName must be all lowercase alphanumeric characters and start with a character.".invalidNec
+        (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) =>
+          name
+        }
+      case _ => "SubmitterName must not be empty!".invalidNec
+    }
+}
+
+/** Extractor to retrieve an SubmitterName from a path parameter.
+  */
+object SubmitterNamePathParameter {
+  def unapply(str: String): Option[SubmitterName] =
+    Option(str).flatMap { string =>
+      if (string.startsWith("~"))
+        SubmitterName.from(string.drop(1))
+      else
+        None
+    }
+}
+
+/** The submitter for a ticket i.e. the person supposed to be working on it.
+  *
+  * @param id
+  *   A globally unique ID identifying the submitter.
+  * @param name
+  *   The name associated with the submitter which is supposed to be unique.
+  */
+final case class Submitter(id: SubmitterId, name: SubmitterName)
+
+object Submitter {
+  given Eq[Submitter] = Eq.fromUniversalEquals
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,193 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import fs2.Stream
+
+/** The base class that defines the needed functionality to handle tickets and related data types within a database.
+  *
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
+  */
+abstract class TicketRepository[F[_]] {
+
+  /** Add the given assignee to the ticket of the given repository id.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry.
+    * @param ticketNumber
+    *   The unique identifier of a ticket within the project scope is its number.
+    * @param assignee
+    *   The assignee to be added to the ticket.
+    * @return
+    *   The number of affected database rows.
+    */
+  def addAssignee(vcsRepositoryId: Long)(ticketNumber: TicketNumber)(assignee: Assignee): F[Int]
+
+  /** Add the given label to the ticket of the given repository id.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry.
+    * @param ticketNumber
+    *   The unique identifier of a ticket within the project scope is its number.
+    * @param label
+    *   The label to be added to the ticket.
+    * @return
+    *   The number of affected database rows.
+    */
+  def addLabel(vcsRepositoryId: Long)(ticketNumber: TicketNumber)(label: Label): F[Int]
+
+  /** Add the given milestone to the ticket of the given repository id.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry.
+    * @param ticketNumber
+    *   The unique identifier of a ticket within the project scope is its number.
+    * @param milestone
+    *   The milestone to be added to the ticket.
+    * @return
+    *   The number of affected database rows.
+    */
+  def addMilestone(vcsRepositoryId: Long)(ticketNumber: TicketNumber)(milestone: Milestone): F[Int]
+
+  /** Return all tickets associated with the given repository.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry for which all tickets shall be returned.
+    * @return
+    *   A stream of tickets associated with the vcs repository which may be empty.
+    */
+  def allTickets(vcsRepositoryId: Long): Stream[F, Ticket]
+
+  /** Create a database entry for the given ticket definition within the scope of the repository with the given id.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry.
+    * @param ticket
+    *   The ticket definition that shall be written to the database.
+    * @return
+    *   The number of affected database rows.
+    */
+  def createTicket(vcsRepositoryId: Long)(ticket: Ticket): F[Int]
+
+  /** Delete the ticket of the repository with the given id from the database.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry.
+    * @param ticket
+    *   The ticket definition that shall be deleted from the database.
+    * @return
+    *   The number of affected database rows.
+    */
+  def deleteTicket(vcsRepositoryId: Long)(ticket: Ticket): F[Int]
+
+  /** Find the ticket with the given number of the repository with the given id.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry.
+    * @param ticketNumber
+    *   The unique identifier of a ticket within the project scope is its number.
+    * @return
+    *   An option to the found ticket.
+    */
+  def findTicket(vcsRepositoryId: Long)(ticketNumber: TicketNumber): F[Option[Ticket]]
+
+  /** Load all assignees that are assigned to the ticket with the given number and repository id.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry.
+    * @param ticketNumber
+    *   The unique identifier of a ticket within the project scope is its number.
+    * @return
+    *   A stream of assigness that may be empty.
+    */
+  def loadAssignees(vcsRepositoryId: Long)(ticketNumber: TicketNumber): Stream[F, Assignee]
+
+  /** Load all labels that are attached to the ticket with the given number and repository id.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry.
+    * @param ticketNumber
+    *   The unique identifier of a ticket within the project scope is its number.
+    * @return
+    *   A stream of labels that may be empty.
+    */
+  def loadLabels(vcsRepositoryId: Long)(ticketNumber: TicketNumber): Stream[F, Label]
+
+  /** Load all milestones that are attached to the ticket with the given number and repository id.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry.
+    * @param ticketNumber
+    *   The unique identifier of a ticket within the project scope is its number.
+    * @return
+    *   A stream of milestones that may be empty.
+    */
+  def loadMilestones(vcsRepositoryId: Long)(ticketNumber: TicketNumber): Stream[F, Milestone]
+
+  /** Remove the given assignee from the ticket of the given repository id.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry.
+    * @param ticketNumber
+    *   The unique identifier of a ticket within the project scope is its number.
+    * @param assignee
+    *   The assignee to be removed from the ticket.
+    * @return
+    *   The number of affected database rows.
+    */
+  def removeAssignee(vcsRepositoryId: Long)(ticket: Ticket)(assignee: Assignee): F[Int]
+
+  /** Remove the given label from the ticket of the given repository id.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry.
+    * @param ticketNumber
+    *   The unique identifier of a ticket within the project scope is its number.
+    * @param label
+    *   The label to be removed from the ticket.
+    * @return
+    *   The number of affected database rows.
+    */
+  def removeLabel(vcsRepositoryId: Long)(ticket: Ticket)(label: Label): F[Int]
+
+  /** Remove the given milestone from the ticket of the given repository id.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry.
+    * @param ticketNumber
+    *   The unique identifier of a ticket within the project scope is its number.
+    * @param milestone
+    *   The milestone to be removed from the ticket.
+    * @return
+    *   The number of affected database rows.
+    */
+  def removeMilestone(vcsRepositoryId: Long)(ticket: Ticket)(milestone: Milestone): F[Int]
+
+  /** Update the database entry for the given ticket within the scope of the repository with the given id.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry.
+    * @param ticket
+    *   The ticket definition that shall be updated within the database.
+    * @return
+    *   The number of affected database rows.
+    */
+  def updateTicket(vcsRepositoryId: Long)(ticket: Ticket): F[Int]
+
+}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import java.time.OffsetDateTime
+
+import cats._
+
+/** An unlimited text field which must be not empty to describe the ticket in great detail if needed.
+  */
+opaque type TicketContent = String
+object TicketContent {
+
+  /** Create an instance of TicketContent from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a TicketContent.
+    * @return
+    *   The appropriate instance of TicketContent.
+    */
+  def apply(source: String): TicketContent = source
+
+  /** Try to create an instance of TicketContent from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a TicketContent.
+    * @return
+    *   An option to the successfully converted TicketContent.
+    */
+  def from(source: String): Option[TicketContent] = Option(source).filter(_.nonEmpty)
+
+}
+
+/** A ticket number maps to an integer beneath and has the requirement to be greater than zero.
+  */
+opaque type TicketNumber = Int
+object TicketNumber {
+
+  /** Create an instance of TicketNumber from the given Int type.
+    *
+    * @param source
+    *   An instance of type Int which will be returned as a TicketNumber.
+    * @return
+    *   The appropriate instance of TicketNumber.
+    */
+  def apply(source: Int): TicketNumber = source
+
+  /** Try to create an instance of TicketNumber from the given Int.
+    *
+    * @param source
+    *   A Int that should fulfil the requirements to be converted into a TicketNumber.
+    * @return
+    *   An option to the successfully converted TicketNumber.
+    */
+  def from(source: Int): Option[TicketNumber] = Option(source).filter(_ > 0)
+}
+
+/** Possible states of a ticket which shall model the life cycle from ticket creation until it is closed. To keep things
+  * simple we do not model the kind of a resolution (e.g. fixed, won't fix or so) for a ticket.
+  */
+enum TicketStatus {
+
+  /** The ticket is considered to be a valid ticket / ready to be worked on e.g. a bug has been confirmed to be present.
+    */
+  case Confirmed
+
+  /** The ticket is being worked on i.e. it is in progress.
+    */
+  case InProgress
+
+  /** The ticket is pending and cannot be processed right now. It may be moved to another state or closed depending on
+    * the circumstances. This could be used to model the "blocked" state of Kanban.
+    */
+  case Pending
+
+  /** The ticket is resolved (i.e. closed) and considered done.
+    */
+  case Resolved
+
+  /** The ticket was reported and is open for further investigation. This might map to what is often called "Backlog"
+    * nowadays.
+    */
+  case Submitted
+}
+
+object TicketStatus {
+  given Eq[TicketStatus] = Eq.fromUniversalEquals
+}
+
+/** Possible types of "resolved states" of a ticket.
+  */
+enum TicketResolution {
+
+  /** The behaviour / scenario described in the ticket is caused by the design of the application and not considered to
+    * be a bug.
+    */
+  case ByDesign
+
+  /** The ticket is finally closed and considered done.
+    *
+    * This state can be used to model a review process e.g. a developer can move a ticket to `Fixed` and reviewer and
+    * tester can later move the ticket to `Closed`.
+    */
+  case Closed
+
+  /** The ticket is a duplicate of an already existing one.
+    */
+  case Duplicate
+
+  /** The bug described in the ticket was fixed.
+    */
+  case Fixed
+
+  /** The feature described in the ticket was implemented.
+    */
+  case Implemented
+
+  /** The ticket is considered to be invalid.
+    */
+  case Invalid
+
+  /** The issue described in the ticket will not be fixed.
+    */
+  case WontFix
+}
+
+object TicketResolution {
+  given Eq[TicketResolution] = Eq.fromUniversalEquals
+}
+
+/** A concise and short description of the ticket which should not exceed 80 characters.
+  */
+opaque type TicketTitle = String
+object TicketTitle {
+
+  val MaxLength: Int = 72
+
+  /** Create an instance of TicketTitle from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a TicketTitle.
+    * @return
+    *   The appropriate instance of TicketTitle.
+    */
+  def apply(source: String): TicketTitle = source
+
+  /** Try to create an instance of TicketTitle from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a TicketTitle.
+    * @return
+    *   An option to the successfully converted TicketTitle.
+    */
+  def from(source: String): Option[TicketTitle] =
+    Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
+}
+
+/** An ticket used to describe a problem or a task (e.g. implement a concrete feature) within the scope of a project.
+  *
+  * @param number
+  *   The unique identifier of a ticket within the project scope is its number.
+  * @param title
+  *   A concise and short description of the ticket which should not exceed 72 characters.
+  * @param content
+  *   An optional field to describe the ticket in great detail if needed.
+  * @param status
+  *   The current status of the ticket describing its life cycle.
+  * @param resolution
+  *   An optional resolution state of the ticket that should be set if it is closed.
+  * @param submitter
+  *   The person who submitted (created) this ticket which is optional because of possible account deletion or other
+  *   reasons.
+  * @param createdAt
+  *   The timestamp when the ticket was created / submitted.
+  * @param updatedAt
+  *   The timestamp when the ticket was last updated. Upon creation the update time equals the creation time.
+  */
+final case class Ticket(
+    number: TicketNumber,
+    title: TicketTitle,
+    content: Option[TicketContent],
+    status: TicketStatus,
+    resolution: Option[TicketResolution],
+    submitter: Option[Submitter],
+    createdAt: OffsetDateTime,
+    updatedAt: OffsetDateTime
+)
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.semderee.tickets
+
+import cats._
+import de.smederee.email.EmailAddress
+import de.smederee.security.{ UserId, Username }
+
+/** A user of the tickets service.
+  *
+  * @param uid
+  *   The unique ID of the user.
+  * @param name
+  *   A unique name which can be used for login and to identify the user.
+  * @param email
+  *   The email address of the user which must also be unique.
+  */
+final case class TicketsUser(uid: UserId, name: Username, email: EmailAddress)
+
+object TicketsUser {
+  given Eq[TicketsUser] = Eq.fromUniversalEquals
+}
diff -rN -u old-smederee/modules/tickets/src/test/resources/logback-test.xml new-smederee/modules/tickets/src/test/resources/logback-test.xml
--- old-smederee/modules/tickets/src/test/resources/logback-test.xml	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/resources/logback-test.xml	2025-01-31 10:46:28.186967333 +0000
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration debug="false">
+  <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
+    <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
+      <level>WARN</level>
+    </filter>
+    <encoder>
+      <pattern>%date %highlight(%-5level) %cyan(%logger{0}) - %msg%n</pattern>
+    </encoder>
+  </appender>
+
+  <appender name="async-console" class="ch.qos.logback.classic.AsyncAppender">
+    <appender-ref ref="console"/>
+    <queueSize>5000</queueSize>
+    <discardingThreshold>0</discardingThreshold>
+  </appender>
+
+  <logger name="de.smederee.tickets" level="DEBUG" additivity="false">
+    <appender-ref ref="async-console"/>
+  </logger>
+
+  <root>
+    <appender-ref ref="async-console"/>
+  </root>
+</configuration>
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala	2025-01-31 10:46:28.190967340 +0000
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import de.smederee.tickets.Generators._
+
+import munit._
+import org.scalacheck._
+import org.scalacheck.Prop._
+
+final class ColourCodeTest extends ScalaCheckSuite {
+  given Arbitrary[ColourCode] = Arbitrary(genColourCode)
+
+  property("ColourCode.from must fail on invalid input") {
+    forAll { (input: String) =>
+      assertEquals(ColourCode.from(input), None)
+    }
+  }
+
+  property("ColourCode.from must succeed on valid input") {
+    forAll { (colourCode: ColourCode) =>
+      val input = colourCode.toString
+      assertEquals(ColourCode.from(input), Option(colourCode))
+    }
+  }
+}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala	2025-01-31 10:46:28.190967340 +0000
@@ -0,0 +1,146 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import java.time._
+import java.util.Locale
+
+import cats.syntax.all._
+
+import org.scalacheck.{ Arbitrary, Gen }
+
+import scala.jdk.CollectionConverters._
+
+object Generators {
+  val MinimumYear: Int = -4713  // Lowest year supported by PostgreSQL
+  val MaximumYear: Int = 294276 // Highest year supported by PostgreSQL
+
+  /** Prepend a zero to a single character hexadecimal code.
+    *
+    * @param hexCode
+    *   A string supposed to contain a hexadecimal code between 0 and ff.
+    * @return
+    *   Either the given code prepended with a leading zero if it had only a single character or the originally given
+    *   code otherwise.
+    */
+  private def hexPadding(hexCode: String): String =
+    if (hexCode.length < 2)
+      "0" + hexCode
+    else
+      hexCode
+
+  val genLocalDate: Gen[LocalDate] =
+    for {
+      year  <- Gen.choose(MinimumYear, MaximumYear)
+      month <- Gen.choose(1, 12)
+      day   <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap))
+    } yield LocalDate.of(year, month, day)
+
+  given Arbitrary[LocalDate] = Arbitrary(genLocalDate)
+
+  val genOffsetDateTime: Gen[OffsetDateTime] =
+    for {
+      year       <- Gen.choose(MinimumYear, MaximumYear)
+      month      <- Gen.choose(1, 12)
+      day        <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap))
+      hour       <- Gen.choose(0, 23)
+      minute     <- Gen.choose(0, 59)
+      second     <- Gen.choose(0, 59)
+      nanosecond <- Gen.const(0) // Avoid issues with loosing information during saving and loading.
+      offset <- Gen.oneOf(
+        ZoneId.getAvailableZoneIds.asScala.toList.map(ZoneId.of).map(ZonedDateTime.now).map(_.getOffset)
+      )
+    } yield OffsetDateTime.of(year, month, day, hour, minute, second, nanosecond, offset)
+
+  val genSubmitterId: Gen[SubmitterId] = Gen.delay(SubmitterId.randomSubmitterId)
+
+  val genValidSubmitterName: Gen[SubmitterName] = for {
+    length <- Gen.choose(2, 30)
+    prefix <- Gen.alphaChar
+    chars <- Gen
+      .nonEmptyListOf(Gen.alphaNumChar)
+      .map(_.take(length).mkString.toLowerCase(Locale.ROOT))
+  } yield SubmitterName(prefix.toString.toLowerCase(Locale.ROOT) + chars)
+
+  val genLabelName: Gen[LabelName] =
+    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelName.MaxLength).mkString).map(LabelName.apply)
+
+  val genLabelDescription: Gen[LabelDescription] =
+    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelDescription.MaxLength).mkString).map(LabelDescription.apply)
+
+  val genColourCode: Gen[ColourCode] = for {
+    red   <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
+    green <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
+    blue  <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
+    hexString = s"#$red$green$blue"
+  } yield ColourCode(hexString)
+
+  val genLabel: Gen[Label] = for {
+    id          <- Gen.option(Gen.choose(0L, Long.MaxValue).map(LabelId.apply))
+    name        <- genLabelName
+    description <- Gen.option(genLabelDescription)
+    colour      <- genColourCode
+  } yield Label(id, name, description, colour)
+
+  val genLabels: Gen[List[Label]] = Gen.nonEmptyListOf(genLabel).map(_.distinct)
+
+  val genMilestoneTitle: Gen[MilestoneTitle] =
+    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(MilestoneTitle.MaxLength).mkString).map(MilestoneTitle.apply)
+
+  val genMilestoneDescription: Gen[MilestoneDescription] =
+    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.mkString).map(MilestoneDescription.apply)
+
+  val genMilestone: Gen[Milestone] =
+    for {
+      id    <- Gen.option(Gen.choose(0L, Long.MaxValue).map(MilestoneId.apply))
+      title <- genMilestoneTitle
+      due   <- Gen.option(genLocalDate)
+      descr <- Gen.option(genMilestoneDescription)
+    } yield Milestone(id, title, descr, due)
+
+  val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct)
+
+  val genSubmitter: Gen[Submitter] =
+    for {
+      uid  <- genSubmitterId
+      name <- genValidSubmitterName
+    } yield Submitter(uid, name)
+
+  val genTicket: Gen[Ticket] =
+    for {
+      ticketNumber <- Gen.choose(0, Int.MaxValue).map(TicketNumber.apply)
+      ticketTitle <- Gen
+        .nonEmptyListOf(Gen.alphaNumChar)
+        .map(chars => TicketTitle(chars.take(TicketTitle.MaxLength).mkString))
+      ticketContent    <- Gen.alphaNumStr.map(TicketContent.from)
+      ticketStatus     <- Gen.oneOf(TicketStatus.values.toList)
+      ticketResolution <- Gen.option(Gen.oneOf(TicketResolution.values.toList))
+      submitter        <- Gen.option(genSubmitter)
+      createdAt        <- genOffsetDateTime
+      updatedAt        <- genOffsetDateTime
+    } yield Ticket(
+      number = ticketNumber,
+      title = ticketTitle,
+      content = ticketContent,
+      status = ticketStatus,
+      resolution = ticketResolution.filter(_ => ticketStatus === TicketStatus.Resolved),
+      submitter = submitter,
+      createdAt = createdAt,
+      updatedAt = updatedAt
+    )
+}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala	2025-01-31 10:46:28.190967340 +0000
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import de.smederee.tickets.Generators._
+
+import munit._
+import org.scalacheck._
+import org.scalacheck.Prop._
+
+final class LabelDescriptionTest extends ScalaCheckSuite {
+  given Arbitrary[LabelDescription] = Arbitrary(genLabelDescription)
+
+  test("LabelDescription.from must fail on empty input") {
+    assertEquals(LabelDescription.from(""), None)
+  }
+
+  property("LabelDescription.from must fail on too long input") {
+    forAll { (input: String) =>
+      if (input.length > LabelDescription.MaxLength)
+        assertEquals(LabelDescription.from(input), None)
+    }
+  }
+
+  property("LabelDescription.from must succeed on valid input") {
+    forAll { (label: LabelDescription) =>
+      val input = label.toString
+      assertEquals(LabelDescription.from(input), Option(label))
+    }
+  }
+}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala	2025-01-31 10:46:28.190967340 +0000
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import de.smederee.tickets.Generators._
+
+import munit._
+import org.scalacheck._
+import org.scalacheck.Prop._
+
+final class LabelNameTest extends ScalaCheckSuite {
+  given Arbitrary[LabelName] = Arbitrary(genLabelName)
+
+  test("LabelName.from must fail on empty input") {
+    assertEquals(LabelName.from(""), None)
+  }
+
+  property("LabelName.from must fail on too long input") {
+    forAll { (input: String) =>
+      if (input.length > LabelName.MaxLength)
+        assertEquals(LabelName.from(input), None)
+    }
+  }
+
+  property("LabelName.from must succeed on valid input") {
+    forAll { (label: LabelName) =>
+      val input = label.toString
+      assertEquals(LabelName.from(input), Option(label))
+    }
+  }
+}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala	2025-01-31 10:46:28.190967340 +0000
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import cats.syntax.all._
+import de.smederee.tickets.Generators._
+
+import munit._
+import org.scalacheck._
+import org.scalacheck.Prop._
+
+final class LabelTest extends ScalaCheckSuite {
+  given Arbitrary[Label] = Arbitrary(genLabel)
+
+  property("Eq must hold") {
+    forAll { (label: Label) =>
+      assert(label === label, "Identical labels must be considered equal!")
+    }
+  }
+}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala	2025-01-31 10:46:28.190967340 +0000
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import de.smederee.tickets.Generators._
+
+import munit._
+import org.scalacheck._
+import org.scalacheck.Prop._
+
+final class MilestoneDescriptionTest extends ScalaCheckSuite {
+  given Arbitrary[MilestoneDescription] = Arbitrary(genMilestoneDescription)
+
+  test("MilestoneDescription.from must fail on empty input") {
+    assertEquals(MilestoneDescription.from(""), None)
+  }
+
+  property("MilestoneDescription.from must succeed on valid input") {
+    forAll { (label: MilestoneDescription) =>
+      val input = label.toString
+      assertEquals(MilestoneDescription.from(input), Option(label))
+    }
+  }
+}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala	2025-01-31 10:46:28.190967340 +0000
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import cats.syntax.all._
+import de.smederee.tickets.Generators._
+
+import munit._
+import org.scalacheck._
+import org.scalacheck.Prop._
+
+final class MilestoneTest extends ScalaCheckSuite {
+  given Arbitrary[Milestone] = Arbitrary(genMilestone)
+
+  property("Eq must hold") {
+    forAll { (label: Milestone) =>
+      assert(label === label, "Identical milestones must be considered equal!")
+    }
+  }
+}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala	2025-01-31 10:46:28.190967340 +0000
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import de.smederee.tickets.Generators._
+
+import munit._
+import org.scalacheck._
+import org.scalacheck.Prop._
+
+final class MilestoneTitleTest extends ScalaCheckSuite {
+  given Arbitrary[MilestoneTitle] = Arbitrary(genMilestoneTitle)
+
+  test("MilestoneTitle.from must fail on empty input") {
+    assertEquals(MilestoneTitle.from(""), None)
+  }
+
+  property("MilestoneTitle.from must fail on too long input") {
+    forAll { (input: String) =>
+      if (input.length > MilestoneTitle.MaxLength)
+        assertEquals(MilestoneTitle.from(input), None)
+    }
+  }
+
+  property("MilestoneTitle.from must succeed on valid input") {
+    forAll { (label: MilestoneTitle) =>
+      val input = label.toString
+      assertEquals(MilestoneTitle.from(input), Option(label))
+    }
+  }
+}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala	2025-01-31 10:46:28.190967340 +0000
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import munit._
+import org.scalacheck._
+import org.scalacheck.Prop._
+
+final class TicketContentTest extends ScalaCheckSuite {
+
+  property("TicketContent.from must only accept valid input") {
+    forAll { (input: String) =>
+      if (input.nonEmpty)
+        assertEquals(TicketContent.from(input), Some(TicketContent(input)))
+      else
+        assertEquals(TicketContent.from(input), None)
+    }
+  }
+
+}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala	2025-01-31 10:46:28.190967340 +0000
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import munit._
+import org.scalacheck._
+import org.scalacheck.Prop._
+
+final class TicketNumberTest extends ScalaCheckSuite {
+
+  property("TicketNumber.from must only accept valid input") {
+    forAll { (integer: Int) =>
+      if (integer > 0)
+        assertEquals(TicketNumber.from(integer), Some(TicketNumber(integer)))
+      else
+        assertEquals(TicketNumber.from(integer), None)
+    }
+  }
+
+}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala	2025-01-31 10:46:28.190967340 +0000
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import munit._
+import org.scalacheck._
+import org.scalacheck.Prop._
+
+final class TicketTitleTest extends ScalaCheckSuite {
+
+  property("TicketTitle.from must only accept valid input") {
+    forAll { (input: String) =>
+      if (input.nonEmpty && input.length <= TicketTitle.MaxLength)
+        assertEquals(TicketTitle.from(input), Some(TicketTitle(input)))
+      else
+        assertEquals(TicketTitle.from(input), None)
+    }
+  }
+
+}
diff -rN -u old-smederee/project/plugins.sbt new-smederee/project/plugins.sbt
--- old-smederee/project/plugins.sbt	2025-01-31 10:46:28.166967299 +0000
+++ new-smederee/project/plugins.sbt	2025-01-31 10:46:28.190967340 +0000
@@ -4,6 +4,6 @@
 addSbtPlugin("ch.epfl.scala"     % "sbt-scalafix"        % "0.10.4")
 addSbtPlugin("org.scalameta"     % "sbt-scalafmt"        % "2.5.0")
 addSbtPlugin("org.scoverage"     % "sbt-scoverage"       % "2.0.6")
-addSbtPlugin("com.typesafe.play" % "sbt-twirl"           % "1.6.0-RC1")
+addSbtPlugin("com.typesafe.play" % "sbt-twirl"           % "1.6.0-RC2")
 // Needed to build debian packages via java (for sbt-native-packager).
 libraryDependencies += "org.vafer" % "jdeb" % "1.10" artifacts (Artifact("jdeb", "jar", "jar"))
diff -rN -u old-smederee/twirl/src/main/scala/org/http4s/twirl/TwirlInstances.scala new-smederee/twirl/src/main/scala/org/http4s/twirl/TwirlInstances.scala
--- old-smederee/twirl/src/main/scala/org/http4s/twirl/TwirlInstances.scala	2025-01-31 10:46:28.166967299 +0000
+++ new-smederee/twirl/src/main/scala/org/http4s/twirl/TwirlInstances.scala	2025-01-31 10:46:28.190967340 +0000
@@ -20,7 +20,6 @@
 
 import _root_.play.twirl.api._
 import org.http4s.Charset.`UTF-8`
-import org.http4s.MediaType
 import org.http4s.headers.`Content-Type`
 
 @SuppressWarnings(Array("scalafix:DisableSyntax.defaultArgs"))