~jan0sch/smederee

Showing details for patch 3ed1ceea17734d3ac7ac029cdcbd918f91878bf3.
2023-05-16 (Tue), 5:57 PM - Jens Grassel - 3ed1ceea17734d3ac7ac029cdcbd918f91878bf3

Tickets: Refactor FormValidator

- use Map[String, Chain[String]] to be more conistent with http4s
- adjust code accordingly
- add default cats imports to twirl templates
Summary of changes
2 files added
  • modules/hub/src/main/twirl/de/smederee/tickets/views/errors/internalServerError.scala.html
  • modules/hub/src/main/twirl/de/smederee/tickets/views/errors/unvalidatedAccount.scala.html
15 files modified with 506 lines added and 404 lines removed
  • build.sbt with 2 added and 0 removed lines
  • modules/hub/src/main/resources/messages.properties with 2 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 8 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala with 27 added and 21 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala with 194 added and 178 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala with 31 added and 27 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala with 7 added and 24 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala with 66 added and 32 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala with 128 added and 93 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html with 24 added and 10 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html with 4 added and 4 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html with 4 added and 4 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html with 4 added and 4 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html with 4 added and 4 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala with 1 added and 1 removed lines
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt
--- old-smederee/build.sbt	2025-01-16 13:03:36.590032984 +0000
+++ new-smederee/build.sbt	2025-01-16 13:03:36.594032983 +0000
@@ -217,6 +217,8 @@
         case module => module
       } ++ Seq("org.scala-lang.modules" %% "scala-xml" % "2.1.0"),
       TwirlKeys.templateImports ++= Seq(
+        "cats._",
+        "cats.data._",
         "cats.syntax.all._",
         "de.smederee.html._",
         "de.smederee.i18n._",
diff -rN -u old-smederee/modules/hub/src/main/resources/messages.properties new-smederee/modules/hub/src/main/resources/messages.properties
--- old-smederee/modules/hub/src/main/resources/messages.properties	2025-01-16 13:03:36.590032984 +0000
+++ new-smederee/modules/hub/src/main/resources/messages.properties	2025-01-16 13:03:36.594032983 +0000
@@ -291,6 +291,8 @@
 form.ticket.create.button.submit=Create ticket
 form.ticket.labels=Labels
 form.ticket.labels.help=Select labels to apply to this ticket.
+form.ticket.milestones=Milestones
+form.ticket.milestones.help=Select the milestones that this ticket is part of.
 form.ticket.resolution=Resolution
 form.ticket.resolution.help=This can be set to further describe the resolution of a ticket.
 form.ticket.status=Status
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-16 13:03:36.590032984 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-16 13:03:36.594032983 +0000
@@ -160,8 +160,14 @@
       ticketMilestonesRepo = new DoobieMilestoneRepository[IO](ticketsTransactor)
       ticketProjectsRepo   = new DoobieProjectRepository[IO](ticketsTransactor)
       ticketsRepo          = new DoobieTicketRepository[IO](ticketsTransactor)
-      ticketRoutes      = new TicketRoutes[IO](ticketsConfiguration, ticketLabelsRepo, ticketProjectsRepo, ticketsRepo)
-      ticketLabelRoutes = new LabelRoutes[IO](ticketsConfiguration, ticketLabelsRepo, ticketProjectsRepo)
+      ticketRoutes = new TicketRoutes[IO](
+        ticketsConfiguration,
+        ticketLabelsRepo,
+        ticketMilestonesRepo,
+        ticketProjectsRepo,
+        ticketsRepo
+      )
+      ticketLabelRoutes     = new LabelRoutes[IO](ticketsConfiguration, ticketLabelsRepo, ticketProjectsRepo)
       ticketMilestoneRoutes = new MilestoneRoutes[IO](ticketsConfiguration, ticketMilestonesRepo, ticketProjectsRepo)
       cryptoClock           = java.time.Clock.systemUTC
       csrfKey <- loadOrCreateCsrfKey(hubConfiguration.service.csrfKeyFile)
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	2025-01-16 13:03:36.590032984 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala	2025-01-16 13:03:36.594032983 +0000
@@ -57,35 +57,41 @@
   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] = {
+  override def validate(data: Map[String, Chain[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)
+      .fold(Option.empty[LabelId].validNec)(
+        _.headOption
+          .flatMap(LabelId.fromString)
+          .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)
+      .fold(FormFieldError("No label name given!").invalidNec)(
+        _.headOption
+          .map(_.trim) // We strip leading and trailing whitespace!
+          .flatMap(LabelName.from)
+          .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)
-      }
+      .fold(Option.empty[LabelDescription].validNec)(
+        _.headOption
+          .map(_.trim)
+          .filter(_.nonEmpty)
+          .fold(none[LabelDescription].validNec)(s =>
+            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)
+      .fold(FormFieldError("No label colour given!").invalidNec)(
+        _.headOption.flatMap(ColourCode.from).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) =>
@@ -101,12 +107,12 @@
       * @return
       *   A stringified map containing the data of the form.
       */
-    def toMap: Map[String, String] =
+    def toMap: Map[String, Chain[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
+        LabelForm.fieldId.toString          -> form.id.map(_.toString).fold(Chain.empty)(id => Chain(id)),
+        LabelForm.fieldName.toString        -> Chain(form.name.toString),
+        LabelForm.fieldDescription.toString -> form.description.map(_.toString).fold(Chain.empty)(d => Chain(d)),
+        LabelForm.fieldColour.toString      -> Chain(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	2025-01-16 13:03:36.590032984 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala	2025-01-16 13:03:36.594032983 +0000
@@ -150,82 +150,83 @@
           projectName
         ) / "labels" as user =>
       ar.req.decodeStrict[F, UrlForm] { urlForm =>
-        for {
-          csrf         <- Sync[F].delay(ar.req.getCsrfToken)
-          language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
-          projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
-          resp <- projectAndId match {
-            case Some(project, projectId) =>
-              for {
-                _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(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))
+        val response =
+          for {
+            csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+            language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+            projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
+            resp <- projectAndId match {
+              case Some(project, projectId) =>
+                for {
+                  _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))(
+                    new Error("Only maintainers may add labels!")
+                  )
+                  formData <- Sync[F].delay(urlForm.values)
+                  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(lang = language)(
-                        projectBaseUri.addSegment("labels"),
-                        csrf,
-                        labels.getOrElse(List.empty),
-                        projectBaseUri,
-                        "Manage your project labels.".some,
-                        user.some,
-                        project
-                      )(formData, FormErrors.fromNec(errors))
-                    )
-                  case Validated.Valid(labelData) =>
-                    val label = Label(None, labelData.name, labelData.description, labelData.colour)
-                    for {
-                      checkDuplicate <- labelRepo.findLabel(projectId)(labelData.name)
-                      resp <- checkDuplicate match {
-                        case None =>
-                          labelRepo.createLabel(projectId)(label) *> SeeOther(
-                            Location(projectBaseUri.addSegment("labels"))
-                          )
-                        case Some(_) =>
-                          BadRequest(
-                            views.html.editLabels(lang = language)(
-                              projectBaseUri.addSegment("labels"),
-                              csrf,
-                              labels.getOrElse(List.empty),
-                              projectBaseUri,
-                              "Manage your project labels.".some,
-                              user.some,
-                              project
-                            )(
-                              formData,
-                              Map(
-                                LabelForm.fieldName -> List(FormFieldError("A label with that name already exists!"))
+                  resp <- form match {
+                    case Validated.Invalid(errors) =>
+                      BadRequest(
+                        views.html.editLabels(lang = language)(
+                          projectBaseUri.addSegment("labels"),
+                          csrf,
+                          labels.getOrElse(List.empty),
+                          projectBaseUri,
+                          "Manage your project labels.".some,
+                          user.some,
+                          project
+                        )(formData.withDefaultValue(Chain.empty), FormErrors.fromNec(errors))
+                      )
+                    case Validated.Valid(labelData) =>
+                      val label = Label(None, labelData.name, labelData.description, labelData.colour)
+                      for {
+                        checkDuplicate <- labelRepo.findLabel(projectId)(labelData.name)
+                        resp <- checkDuplicate match {
+                          case None =>
+                            labelRepo.createLabel(projectId)(label) *> SeeOther(
+                              Location(projectBaseUri.addSegment("labels"))
+                            )
+                          case Some(_) =>
+                            BadRequest(
+                              views.html.editLabels(lang = language)(
+                                projectBaseUri.addSegment("labels"),
+                                csrf,
+                                labels.getOrElse(List.empty),
+                                projectBaseUri,
+                                "Manage your project labels.".some,
+                                user.some,
+                                project
+                              )(
+                                formData.withDefaultValue(Chain.empty),
+                                Map(
+                                  LabelForm.fieldName -> List(FormFieldError("A label with that name already exists!"))
+                                )
                               )
                             )
-                          )
-                      }
-                    } yield resp
-                }
-              } yield resp
-            case _ => NotFound()
-          }
-        } yield resp
+                        }
+                      } yield resp
+                  }
+                } yield resp
+              case _ => NotFound()
+            }
+          } yield resp
+        response.recoverWith { error =>
+          log.error("Internal Server Error", error)
+          for {
+            csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+            language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+            resp     <- InternalServerError(views.html.errors.internalServerError(lang = language)(csrf, user.some))
+          } yield resp
+        }
       }
   }
 
@@ -247,15 +248,7 @@
                 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)!
-                        }
-                      }
+                      formData <- Sync[F].delay(urlForm.values)
                       projectBaseUri <- Sync[F].delay(
                         linkConfig.createFullUri(
                           Uri(path =
@@ -268,15 +261,24 @@
                           )
                         )
                       )
-                      userIsSure <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes"))
+                      userIsSure <- Sync[F].delay(
+                        formData.get("i-am-sure").map(_.headOption.exists(_ === "yes")).getOrElse(false)
+                      )
                       labelIdMatches <- Sync[F].delay(
                         formData
                           .get(LabelForm.fieldId)
-                          .flatMap(LabelId.fromString)
-                          .exists(id => label.id.exists(_ === id))
+                          .map(
+                            _.headOption
+                              .flatMap(LabelId.fromString)
+                              .exists(id => label.id.exists(_ === id))
+                          )
+                          .getOrElse(false)
                       )
                       labelNameMatches <- Sync[F].delay(
-                        formData.get(LabelForm.fieldName).flatMap(LabelName.from).exists(_ === labelName)
+                        formData
+                          .get(LabelForm.fieldName)
+                          .map(_.headOption.flatMap(LabelName.from).exists(_ === labelName))
+                          .getOrElse(false)
                       )
                       resp <- (labelIdMatches && labelNameMatches && userIsSure) match {
                         case false => BadRequest("Invalid form data!")
@@ -300,102 +302,107 @@
           projectName
         ) / "label" / LabelNamePathParameter(labelName) as user =>
       ar.req.decodeStrict[F, UrlForm] { urlForm =>
-        for {
-          csrf         <- Sync[F].delay(ar.req.getCsrfToken)
-          language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
-          projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
-          label <- projectAndId match {
-            case Some((_, projectId)) => labelRepo.findLabel(projectId)(labelName)
-            case _                    => Sync[F].delay(None)
-          }
-          resp <- (projectAndId, label) match {
-            case (Some(project, projectId), Some(label)) =>
-              for {
-                _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(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))
+        val response =
+          for {
+            csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+            language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+            projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
+            label <- projectAndId match {
+              case Some((_, projectId)) => labelRepo.findLabel(projectId)(labelName)
+              case _                    => Sync[F].delay(None)
+            }
+            resp <- (projectAndId, label) match {
+              case (Some(project, projectId), Some(label)) =>
+                for {
+                  _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(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(lang = language)(
-                        actionUri,
-                        csrf,
-                        label,
-                        projectBaseUri,
-                        s"Edit label ${label.name}".some,
-                        user,
-                        project
-                      )(
-                        formData.toMap,
-                        FormErrors.fromNec(errors)
+                  actionUri <- Sync[F].delay(projectBaseUri.addSegment("label").addSegment(label.name.toString))
+                  formData  <- Sync[F].delay(urlForm.values)
+//                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(lang = language)(
+                          actionUri,
+                          csrf,
+                          label,
+                          projectBaseUri,
+                          s"Edit label ${label.name}".some,
+                          user,
+                          project
+                        )(
+                          formData.toMap.withDefaultValue(Chain.empty),
+                          FormErrors.fromNec(errors)
+                        )
                       )
-                    )
-                  case Validated.Valid(labelData) =>
-                    val updatedLabel =
-                      label.copy(name = labelData.name, description = labelData.description, colour = labelData.colour)
-                    for {
-                      checkDuplicate <- labelRepo.findLabel(projectId)(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(lang = language)(
-                              actionUri,
-                              csrf,
-                              label,
-                              projectBaseUri,
-                              s"Edit label ${label.name}".some,
-                              user,
-                              project
-                            )(
-                              formData,
-                              Map(
-                                LabelForm.fieldName -> List(FormFieldError("A label with that name already exists!"))
+                    case Validated.Valid(labelData) =>
+                      val updatedLabel =
+                        label.copy(
+                          name = labelData.name,
+                          description = labelData.description,
+                          colour = labelData.colour
+                        )
+                      for {
+                        checkDuplicate <- labelRepo.findLabel(projectId)(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(lang = language)(
+                                actionUri,
+                                csrf,
+                                label,
+                                projectBaseUri,
+                                s"Edit label ${label.name}".some,
+                                user,
+                                project
+                              )(
+                                formData.toMap.withDefaultValue(Chain.empty),
+                                Map(
+                                  LabelForm.fieldName -> List(FormFieldError("A label with that name already exists!"))
+                                )
                               )
                             )
-                          )
-                      }
-                    } yield resp
-                }
-              } yield resp
-            case _ => NotFound()
-          }
-        } yield resp
+                        }
+                      } yield resp
+                  }
+                } yield resp
+              case _ => NotFound()
+            }
+          } yield resp
+        response.recoverWith { error =>
+          log.error("Internal Server Error", error)
+          for {
+            csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+            language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+            resp     <- InternalServerError(views.html.errors.internalServerError(lang = language)(csrf, user.some))
+          } yield resp
+        }
       }
   }
 
@@ -440,10 +447,19 @@
     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
+      val response =
+        for {
+          csrf <- Sync[F].delay(ar.req.getCsrfToken)
+          resp <- doShowLabels(csrf)(user.some)(projectOwnerName)(projectName)
+        } yield resp
+      response.recoverWith { error =>
+        log.error("Internal Server Error", error)
+        for {
+          csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+          language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+          resp     <- InternalServerError(views.html.errors.internalServerError(lang = language)(csrf, user.some))
+        } yield resp
+      }
   }
 
   private val showLabelsForGuests: HttpRoutes[F] = HttpRoutes.of {
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	2025-01-16 13:03:36.590032984 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala	2025-01-16 13:03:36.594032983 +0000
@@ -65,41 +65,45 @@
       dueDate = milestone.dueDate
     )
 
-  override def validate(data: Map[String, String]): ValidatedNec[FormErrors, MilestoneForm] = {
+  override def validate(data: Map[String, Chain[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)
+      .fold(Option.empty[MilestoneId].validNec)(
+        _.headOption
+          .flatMap(MilestoneId.fromString)
+          .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)
+      .fold(FormFieldError("No milestone title given!").invalidNec)(
+        _.headOption
+          .map(_.trim) // We strip leading and trailing whitespace!
+          .flatMap(MilestoneTitle.from)
+          .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)
-      }
+      .fold(Option.empty[MilestoneDescription].validNec)(
+        _.headOption
+          .map(_.trim)
+          .filter(_.nonEmpty)
+          .fold(none[MilestoneDescription].validNec)(s =>
+            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))
-      }
+      .fold(Option.empty[LocalDate].validNec)(
+        _.headOption
+          .map(_.trim)
+          .filter(_.nonEmpty)
+          .fold(none[LocalDate].validNec)(s => Validated.catchNonFatal(LocalDate.parse(s)).map(date => date.some))
+      )
       .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)
@@ -114,12 +118,12 @@
       * @return
       *   A stringified map containing the data of the form.
       */
-    def toMap: Map[String, String] =
+    def toMap: Map[String, Chain[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("")
+        MilestoneForm.fieldId.toString          -> form.id.map(_.toString).fold(Chain.empty)(id => Chain(id)),
+        MilestoneForm.fieldTitle.toString       -> Chain(form.title.toString),
+        MilestoneForm.fieldDescription.toString -> form.description.map(_.toString).fold(Chain.empty)(d => Chain(d)),
+        MilestoneForm.fieldDueDate.toString     -> form.dueDate.map(_.toString).fold(Chain.empty)(date => Chain(date))
       )
   }
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2025-01-16 13:03:36.594032983 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2025-01-16 13:03:36.594032983 +0000
@@ -157,15 +157,7 @@
                 _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(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)!
-                  }
-                }
+                formData   <- Sync[F].delay(urlForm.values)
                 form       <- Sync[F].delay(MilestoneForm.validate(formData))
                 milestones <- projectAndId.traverse(tuple => milestoneRepo.allMilestones(tuple._2).compile.toList)
                 projectBaseUri <- Sync[F].delay(
@@ -326,26 +318,17 @@
                 actionUri <- Sync[F].delay(
                   projectBaseUri.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)!
-                  }
-                }
+                formData <- Sync[F].delay(urlForm.values)
                 milestoneIdMatches <- Sync[F].delay(
                   formData
                     .get(MilestoneForm.fieldId)
-                    .flatMap(MilestoneId.fromString)
-                    .exists(id => milestone.id.exists(_ === id)) match {
-                    case false =>
+                    .flatMap(_.headOption.flatMap(MilestoneId.fromString))
+                    .find(id => milestone.id.exists(_ === id))
+                    .fold(
                       NonEmptyChain
                         .of(Map(MilestoneForm.fieldGlobal -> List(FormFieldError("Milestone ID does not match!"))))
                         .invalidNec
-                    case true => milestone.id.validNec
-                  }
+                    )(_ => milestone.id.validNec)
                 )
                 form <- Sync[F].delay(MilestoneForm.validate(formData))
                 resp <- form match {
@@ -360,7 +343,7 @@
                         user,
                         project
                       )(
-                        formData.toMap,
+                        formData,
                         FormErrors.fromNec(errors)
                       )
                     )
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala	2025-01-16 13:03:36.594032983 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala	2025-01-16 13:03:36.594032983 +0000
@@ -40,6 +40,10 @@
   * @param submitter
   *   The unique id of the person who submitted (created) this ticket which is optional because of possible account
   *   deletion or other reasons.
+  * @param labels
+  *   A list of label ids which are associated with the ticket.
+  * @param milestones
+  *   A list of milestone ids which are associated with the ticket.
   */
 final case class TicketForm(
     number: Option[TicketNumber],
@@ -47,79 +51,109 @@
     content: Option[TicketContent],
     status: TicketStatus,
     resolution: Option[TicketResolution],
-    submitter: Option[SubmitterId]
+    submitter: Option[SubmitterId],
+    labels: List[LabelId],
+    milestones: List[MilestoneId]
 )
 
 object TicketForm extends FormValidator[TicketForm] {
-  val fieldNumber: FormField     = FormField("number")
-  val fieldTitle: FormField      = FormField("title")
   val fieldContent: FormField    = FormField("content")
-  val fieldStatus: FormField     = FormField("status")
+  val fieldLabels: FormField     = FormField("labels")
+  val fieldMilestones: FormField = FormField("milestones")
+  val fieldNumber: FormField     = FormField("number")
   val fieldResolution: FormField = FormField("resolution")
+  val fieldStatus: FormField     = FormField("status")
   val fieldSubmitter: FormField  = FormField("submitter")
+  val fieldTitle: FormField      = FormField("title")
 
   /** Create a form for editing a ticket from the given ticket data.
     *
+    * @param labels
+    *   A list of label ids which are associated with the ticket.
+    * @param milestones
+    *   A list of milestone ids which are associated with the ticket.
     * @param ticket
     *   The ticket which provides the data for the edit form.
     * @return
     *   A ticket form filled with the data from the given ticket.
     */
-  def fromTicket(ticket: Ticket): TicketForm =
+  def fromTicket(labels: List[LabelId])(milestones: List[MilestoneId])(ticket: Ticket): TicketForm =
     TicketForm(
       number = ticket.number.some,
       title = ticket.title,
       content = ticket.content,
       status = ticket.status,
       resolution = ticket.resolution,
-      submitter = ticket.submitter.map(_.id)
+      submitter = ticket.submitter.map(_.id),
+      labels = labels,
+      milestones = milestones
     )
 
-  override def validate(data: Map[String, String]): ValidatedNec[FormErrors, TicketForm] = {
+  override def validate(data: Map[String, Chain[String]]): ValidatedNec[FormErrors, TicketForm] = {
     val number = data
       .get(fieldNumber)
-      .fold(Option.empty[TicketNumber].validNec)(s =>
-        TicketNumber
-          .fromString(s)
+      .fold(Option.empty[TicketNumber].validNec)(
+        _.headOption
+          .flatMap(TicketNumber.fromString)
           .fold(FormFieldError("Invalid ticket number!").invalidNec)(number => Option(number).validNec)
       )
       .leftMap(es => NonEmptyChain.of(Map(fieldNumber -> es.toList)))
     val title = data
       .get(fieldTitle)
-      .map(_.trim)
-      .fold(FormFieldError("No ticket title given!").invalidNec)(s =>
-        TicketTitle.from(s).fold(FormFieldError("Invalid ticket title!").invalidNec)(_.validNec)
+      .fold(FormFieldError("No ticket title given!").invalidNec)(
+        _.headOption
+          .map(_.trim)
+          .flatMap(TicketTitle.from)
+          .fold(FormFieldError("Invalid ticket title!").invalidNec)(_.validNec)
       )
       .leftMap(es => NonEmptyChain.of(Map(fieldTitle -> es.toList)))
     val content = data
       .get(fieldContent)
-      .filter(_.nonEmpty)
-      .fold(none[TicketContent].validNec)(s =>
-        TicketContent.from(s).fold(FormFieldError("Invalid ticket content!").invalidNec)(_.some.validNec)
+      .fold(none[TicketContent].validNec)(
+        _.headOption
+          .filter(_.nonEmpty)
+          .fold(none[TicketContent].validNec)(s =>
+            TicketContent.from(s).fold(FormFieldError("Invalid ticket content!").invalidNec)(_.some.validNec)
+          )
       )
       .leftMap(es => NonEmptyChain.of(Map(fieldContent -> es.toList)))
     val status = data
       .get(fieldStatus)
-      .fold(FormFieldError("No ticket status given!").invalidNec)(s =>
-        Try(TicketStatus.valueOf(s)).toOption.fold(FormFieldError("Invalid ticket status!").invalidNec)(_.validNec)
+      .fold(FormFieldError("No ticket status given!").invalidNec)(
+        _.headOption
+          .flatMap(s => Try(TicketStatus.valueOf(s)).toOption)
+          .fold(FormFieldError("Invalid ticket status!").invalidNec)(_.validNec)
       )
       .leftMap(es => NonEmptyChain.of(Map(fieldStatus -> es.toList)))
     val resolution = data
       .get(fieldResolution)
-      .fold(none[TicketResolution].validNec)(s =>
-        Try(TicketResolution.valueOf(s)).toOption
+      .fold(none[TicketResolution].validNec)(
+        _.headOption
+          .flatMap(s => Try(TicketResolution.valueOf(s)).toOption)
           .fold(FormFieldError("Invalid ticket resolution!").invalidNec)(_.some.validNec)
       )
       .leftMap(es => NonEmptyChain.of(Map(fieldResolution -> es.toList)))
     val submitterId = data
       .get(fieldSubmitter)
-      .fold(none[SubmitterId].validNec)(s =>
-        SubmitterId.fromString(s).toOption.fold(FormFieldError("Invalid submitter id!").invalidNec)(_.some.validNec)
+      .fold(none[SubmitterId].validNec)(
+        _.headOption
+          .flatMap(s => SubmitterId.fromString(s).toOption)
+          .fold(FormFieldError("Invalid submitter id!").invalidNec)(_.some.validNec)
       )
       .leftMap(es => NonEmptyChain.of(Map(fieldSubmitter -> es.toList)))
-    (number, title, content, status, resolution, submitterId).mapN {
-      case (number, title, content, status, resolution, submitterId) =>
-        TicketForm(number, title, content, status, resolution, submitterId)
+    val labelIds =
+      data
+        .get(fieldLabels)
+        .fold(List.empty[LabelId].validNec[FormErrors])(_.toList.flatMap(LabelId.fromString).validNec[FormErrors])
+    val milestoneIds =
+      data
+        .get(fieldMilestones)
+        .fold(List.empty[MilestoneId].validNec[FormErrors])(
+          _.toList.flatMap(MilestoneId.fromString).validNec[FormErrors]
+        )
+    (number, title, content, status, resolution, submitterId, labelIds, milestoneIds).mapN {
+      case (number, title, content, status, resolution, submitterId, labelIds, milestoneIds) =>
+        TicketForm(number, title, content, status, resolution, submitterId, labelIds, milestoneIds)
     }
   }
 
@@ -131,14 +165,14 @@
       * @return
       *   A stringified map containing the data of the form.
       */
-    def toMap: Map[String, String] =
+    def toMap: Map[String, Chain[String]] =
       Map(
-        TicketForm.fieldNumber.toString     -> form.number.map(_.toString).getOrElse(""),
-        TicketForm.fieldTitle.toString      -> form.title.toString,
-        TicketForm.fieldContent.toString    -> form.content.map(_.toString).getOrElse(""),
-        TicketForm.fieldStatus.toString     -> form.status.toString,
-        TicketForm.fieldResolution.toString -> form.resolution.map(_.toString).getOrElse(""),
-        TicketForm.fieldSubmitter.toString  -> form.submitter.map(_.toString).getOrElse("")
+        TicketForm.fieldNumber.toString     -> form.number.map(_.toString).fold(Chain.empty)(nr => Chain(nr)),
+        TicketForm.fieldTitle.toString      -> Chain(form.title.toString),
+        TicketForm.fieldContent.toString    -> form.content.map(_.toString).fold(Chain.empty)(c => Chain(c)),
+        TicketForm.fieldStatus.toString     -> Chain(form.status.toString),
+        TicketForm.fieldResolution.toString -> form.resolution.map(_.toString).fold(Chain.empty)(r => Chain(r)),
+        TicketForm.fieldSubmitter.toString  -> form.submitter.map(_.toString).fold(Chain.empty)(s => Chain(s))
       )
   }
 }
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2025-01-16 13:03:36.594032983 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2025-01-16 13:03:36.594032983 +0000
@@ -44,6 +44,8 @@
   *   The ticket service configuration.
   * @param labelRepo
   *   A repository for handling database operations for labels.
+  * @param milestoneRepo
+  *   A repository for handling database operations for milestones.
   * @param projectRepo
   *   A repository for handling database operations regarding our projects and their metadata.
   * @param ticketRepo
@@ -54,6 +56,7 @@
 final class TicketRoutes[F[_]: Async](
     configuration: SmedereeTicketsConfiguration,
     labelRepo: LabelRepository[F],
+    milestoneRepo: MilestoneRepository[F],
     projectRepo: ProjectRepository[F],
     ticketRepo: TicketRepository[F]
 ) extends Http4sDsl[F] {
@@ -211,6 +214,84 @@
           projectName
         ) / "tickets" as user =>
       ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        val response =
+          for {
+            csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+            language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+            projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
+            resp <- projectAndId match {
+              case Some((project, projectId)) =>
+                for {
+                  _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))(
+                    new Error("Only maintainers may add milestones!")
+                  )
+                  labels     <- labelRepo.allLabels(projectId).compile.toList
+                  milestones <- milestoneRepo.allMilestones(projectId).compile.toList
+                  formData   <- Sync[F].delay(urlForm.values)
+                  form       <- Sync[F].delay(TicketForm.validate(formData))
+                  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.createTicket(lang = language)(
+                          projectBaseUri.addSegment("tickets"),
+                          csrf,
+                          labels,
+                          milestones,
+                          projectBaseUri,
+                          "Create a new ticket.".some,
+                          user.some,
+                          project
+                        )(formData.withDefaultValue(Chain.empty), FormErrors.fromNec(errors))
+                      )
+                    case Validated.Valid(ticketData) =>
+                      for {
+                        timestamp <- Sync[F].delay(OffsetDateTime.now(ZoneOffset.UTC))
+                        number    <- projectRepo.incrementNextTicketNumber(projectId)
+                        ticket <- Sync[F].delay(
+                          Ticket(
+                            number = number,
+                            title = ticketData.title,
+                            content = ticketData.content,
+                            status = ticketData.status,
+                            resolution = ticketData.resolution,
+                            submitter = Submitter(SubmitterId(user.uid.toUUID), SubmitterName(user.name.toString)).some,
+                            createdAt = timestamp,
+                            updatedAt = timestamp
+                          )
+                        )
+                        _    <- ticketRepo.createTicket(projectId)(ticket)
+                        resp <- SeeOther(Location(projectBaseUri.addSegment("tickets")))
+                      } yield resp
+                  }
+                } yield resp
+              case _ => NotFound()
+            }
+          } yield resp
+      response.recoverWith { error =>
+        log.error("Internal Server Error", error)
+        for {
+          csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+          language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+          resp     <- InternalServerError(views.html.errors.internalServerError(lang = language)(csrf, user.some))
+        } yield resp
+      }
+      }
+  }
+
+  private val showCreateTicketPage: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
+          projectName
+        ) / "newTicket" as user =>
+      val response =
         for {
           csrf         <- Sync[F].delay(ar.req.getCsrfToken)
           language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
@@ -218,20 +299,8 @@
           resp <- projectAndId match {
             case Some((project, projectId)) =>
               for {
-                _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))(
-                  new Error("Only maintainers may add milestones!")
-                )
-                labels <- labelRepo.allLabels(projectId).compile.toList
-                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(TicketForm.validate(formData))
+                labels     <- labelRepo.allLabels(projectId).compile.toList
+                milestones <- milestoneRepo.allMilestones(projectId).compile.toList
                 projectBaseUri <- Sync[F].delay(
                   linkConfig.createFullUri(
                     Uri(path =
@@ -241,92 +310,49 @@
                     )
                   )
                 )
-                resp <- form match {
-                  case Validated.Invalid(errors) =>
-                    BadRequest(
-                      views.html.createTicket(lang = language)(
-                        projectBaseUri.addSegment("tickets"),
-                        csrf,
-                        labels,
-                        projectBaseUri,
-                        "Create a new ticket.".some,
-                        user.some,
-                        project
-                      )(formData, FormErrors.fromNec(errors))
-                    )
-                  case Validated.Valid(ticketData) =>
-                    for {
-                      timestamp <- Sync[F].delay(OffsetDateTime.now(ZoneOffset.UTC))
-                      number    <- projectRepo.incrementNextTicketNumber(projectId)
-                      ticket <- Sync[F].delay(
-                        Ticket(
-                          number = number,
-                          title = ticketData.title,
-                          content = ticketData.content,
-                          status = ticketData.status,
-                          resolution = ticketData.resolution,
-                          submitter = Submitter(SubmitterId(user.uid.toUUID), SubmitterName(user.name.toString)).some,
-                          createdAt = timestamp,
-                          updatedAt = timestamp
-                        )
-                      )
-                      _    <- ticketRepo.createTicket(projectId)(ticket)
-                      resp <- SeeOther(Location(projectBaseUri.addSegment("tickets")))
-                    } yield resp
-                }
+                resp <- Ok(
+                  views.html.createTicket(lang = language)(
+                    projectBaseUri.addSegment("tickets"),
+                    csrf,
+                    labels,
+                    milestones,
+                    projectBaseUri,
+                    "Create a new ticket.".some,
+                    user.some,
+                    project
+                  )()
+                )
               } yield resp
             case _ => NotFound()
           }
         } yield resp
+      response.recoverWith { error =>
+        log.error("Internal Server Error", error)
+        for {
+          csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+          language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+          resp     <- InternalServerError(views.html.errors.internalServerError(lang = language)(csrf, user.some))
+        } yield resp
       }
   }
 
-  private val showCreateTicketPage: AuthedRoutes[Account, F] = AuthedRoutes.of {
-    case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
-          projectName
-        ) / "newTicket" as user =>
-      for {
-        csrf         <- Sync[F].delay(ar.req.getCsrfToken)
-        language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
-        projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
-        resp <- projectAndId match {
-          case Some((project, projectId)) =>
-            for {
-              labels <- labelRepo.allLabels(projectId).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.createTicket(lang = language)(
-                  projectBaseUri.addSegment("tickets"),
-                  csrf,
-                  labels,
-                  projectBaseUri,
-                  "Create a new ticket.".some,
-                  user.some,
-                  project
-                )()
-              )
-            } yield resp
-          case _ => NotFound()
-        }
-      } yield resp
-  }
-
   private val showTicketPage: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
           projectName
         ) / "tickets" / TicketNumberPathParameter(ticketNumber) as user =>
-      for {
-        csrf <- Sync[F].delay(ar.req.getCsrfToken)
-        resp <- doShowTicket(csrf)(user.some)(projectOwnerName)(projectName)(ticketNumber)
-      } yield resp
+      val response =
+        for {
+          csrf <- Sync[F].delay(ar.req.getCsrfToken)
+          resp <- doShowTicket(csrf)(user.some)(projectOwnerName)(projectName)(ticketNumber)
+        } yield resp
+      response.recoverWith { error =>
+        log.error("Internal Server Error", error)
+        for {
+          csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+          language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+          resp     <- InternalServerError(views.html.errors.internalServerError(lang = language)(csrf, user.some))
+        } yield resp
+      }
   }
 
   private val showTicketPageForGuests: HttpRoutes[F] = HttpRoutes.of {
@@ -343,10 +369,19 @@
     case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
           projectName
         ) / "tickets" as user =>
-      for {
-        csrf <- Sync[F].delay(ar.req.getCsrfToken)
-        resp <- doShowTickets(None)(csrf)(user.some)(projectOwnerName)(projectName)
-      } yield resp
+      val response =
+        for {
+          csrf <- Sync[F].delay(ar.req.getCsrfToken)
+          resp <- doShowTickets(None)(csrf)(user.some)(projectOwnerName)(projectName)
+        } yield resp
+      response.recoverWith { error =>
+        log.error("Internal Server Error", error)
+        for {
+          csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+          language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+          resp     <- InternalServerError(views.html.errors.internalServerError(lang = language)(csrf, user.some))
+        } yield resp
+      }
   }
 
   private val showTicketsForGuests: HttpRoutes[F] = HttpRoutes.of {
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html	2025-01-16 13:03:36.594032983 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html	2025-01-16 13:03:36.594032983 +0000
@@ -11,11 +11,12 @@
 )(action: Uri,
   csrf: Option[CsrfToken] = None,
   labels: List[Label] = Nil,
+  milestones: List[Milestone] = Nil,
   projectBaseUri: Uri,
   title: Option[String] = None,
   user: Option[Account],
   project: Project
-)(formData: Map[String, String] = Map.empty,
+)(formData: Map[String, Chain[String]] = Map.empty.withDefaultValue(Chain.empty),
   formErrors: FormErrors = FormErrors.empty
 )
 @footer = {
@@ -71,31 +72,44 @@
                 <div class="pure-u-18-24 pure-u-md-18-24">
                   <div class="pure-control-group pure-u-23-24">
                     <label for="@{fieldTitle}">@Messages("form.ticket.title")</label>
-                    <input class="pure-input-1" id="@{fieldTitle}" name="@{fieldTitle}" maxlength="72" placeholder="@Messages("form.ticket.title.placeholder")" required="required" type="text" value="@{formData.get(fieldTitle)}">
+                    <input class="pure-input-1" id="@{fieldTitle}" name="@{fieldTitle}" maxlength="72" placeholder="@Messages("form.ticket.title.placeholder")" required="required" type="text" value="@{formData(fieldTitle).headOption}">
                     @renderFormErrors(fieldTitle, formErrors)
                   </div>
                   <div class="pure-control-group pure-u-23-24">
                     <label for="@{fieldContent}">@Messages("form.ticket.content")</label>
-                    <textarea class="pure-input-1" id="@{fieldContent}" name="@{fieldContent}" placeholder="@Messages("form.ticket.content.placeholder")" rows="8" value="@{formData.get(fieldContent)}"></textarea>
+                    <textarea class="pure-input-1" id="@{fieldContent}" name="@{fieldContent}" placeholder="@Messages("form.ticket.content.placeholder")" rows="8" value="@{formData(fieldContent).headOption}"></textarea>
                     <span class="pure-form-message" id="@{fieldContent}.help">@Messages("form.ticket.content.help")</span>
                     @renderFormErrors(fieldContent, formErrors)
                   </div>
                 </div>
                 <div class="pure-6-24 pure-u-md-6-24">
                   <div class="pure-control-group">
-                    <label for="ticket_labels">@Messages("form.ticket.labels")</label>
-                    <select class="pure-input-1" id="ticket_labels" name="ticket_labels" multiple>
+                    <label for="@fieldLabels">@Messages("form.ticket.labels")</label>
+                    <select class="pure-input-1" id="@fieldLabels" name="@fieldLabels" multiple>
                       @for(label <- labels) {
-                        <option value="@label.id">@label.name</option>
+                        @for(labelId <- label.id) {
+                          <option value="@labelId" @if(formData(fieldLabels).exists(_ === labelId.toString)){selected}else{}>@label.name</option>
+                        }
                       }
                     </select>
-                    <span class="pure-form-message" id="ticket_labels.help">@Messages("form.ticket.labels.help")</span>
+                    <span class="pure-form-message" id="@{fieldLabels}.help">@Messages("form.ticket.labels.help")</span>
+                  </div>
+                  <div class="pure-control-group">
+                    <milestone for="@fieldMilestones">@Messages("form.ticket.milestones")</milestone>
+                    <select class="pure-input-1" id="@fieldMilestones" name="@fieldMilestones" multiple>
+                      @for(milestone <- milestones) {
+                        @for(milestoneId <- milestone.id) {
+                          <option value="@milestone.id" @if(formData(fieldMilestones).exists(_ === milestoneId.toString)){selected}else{}>@milestone.title</option>
+                        }
+                      }
+                    </select>
+                    <span class="pure-form-message" id="@{fieldMilestones}.help">@Messages("form.ticket.milestones.help")</span>
                   </div>
                   <div class="pure-control-group">
                     <label for="@{fieldStatus}">@Messages("form.ticket.status")</label>
                     <select class="pure-input-1" id="@{fieldStatus}" name="@{fieldStatus}">
                       @for(status <- TicketStatus.Submitted :: TicketStatus.values.toList.filterNot(_ === TicketStatus.Submitted)) {
-                        <option value="@status" @if(formData.get(fieldStatus).exists(_ === status.toString)){selected="selected"}else{}>@status</option>
+                        <option value="@status" @if(formData(fieldStatus).headOption.exists(_ === status.toString)){selected="selected"}else{}>@status</option>
                       }
                     </select>
                     <span class="pure-form-message" id="@{fieldStatus}.help">@Messages("form.ticket.status.help")</span>
@@ -103,10 +117,10 @@
                   </div>
                   <div class="pure-control-group">
                     <label for="@{fieldResolution}">@Messages("form.ticket.resolution")</label>
-                    <select class="pure-input-1" id="@{fieldResolution}" name="@{fieldResolution}" @if(formData.get(fieldStatus).exists(_ === TicketStatus.Resolved.toString)){}else{disabled="disabled"}>
+                    <select class="pure-input-1" id="@{fieldResolution}" name="@{fieldResolution}" @if(formData(fieldStatus).headOption.exists(_ === TicketStatus.Resolved.toString)){}else{disabled="disabled"}>
                       <option value=""></option>
                       @for(resolution <- TicketResolution.values) {
-                        <option value="@resolution" @if(formData.get(fieldResolution).exists(_ === resolution.toString)){selected="selected"}else{}>@resolution</option>
+                        <option value="@resolution" @if(formData(fieldResolution).headOption.exists(_ === resolution.toString)){selected="selected"}else{}>@resolution</option>
                       }
                     </select>
                     <span class="pure-form-message" id="@{fieldResolution}.help">@Messages("form.ticket.resolution.help")</span>
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	2025-01-16 13:03:36.594032983 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html	2025-01-16 13:03:36.594032983 +0000
@@ -16,7 +16,7 @@
   title: Option[String] = None,
   user: Account,
   project: Project
-)(formData: Map[String, String] = Map.empty,
+)(formData: Map[String, Chain[String]] = Map.empty.withDefaultValue(Chain.empty),
   formErrors: FormErrors = FormErrors.empty
 )
 @main(baseUri, lang)()(csrf, title, user.some) {
@@ -55,19 +55,19 @@
               <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)}">
+                <input id="@{fieldName}" name="@{fieldName}" maxlength="40" placeholder="@Messages("form.label.name.placeholder")" required="" type="text" value="@{formData(fieldName).headOption}">
                 <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)}">
+                <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.label.description.placeholder")" type="text" value="@{formData(fieldDescription).headOption}">
                 <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")}">
+                <input id="@{fieldColour}" name="@{fieldColour}" required="" type="color" value="@{formData(fieldColour).headOption.getOrElse("#B48EAD")}">
                 <span class="pure-form-message" id="@{fieldColour}.help" style="margin-left: 13em;">@Messages("form.label.colour.help")</span>
                 @renderFormErrors(fieldColour, formErrors)
               </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	2025-01-16 13:03:36.594032983 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html	2025-01-16 13:03:36.598032982 +0000
@@ -16,7 +16,7 @@
   title: Option[String] = None,
   user: Option[Account],
   project: Project
-)(formData: Map[String, String] = Map.empty,
+)(formData: Map[String, Chain[String]] = Map.empty.withDefaultValue(Chain.empty),
   formErrors: FormErrors = FormErrors.empty
 )
 @main(baseUri, lang)()(csrf, title, user) {
@@ -58,19 +58,19 @@
             <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)}">
+                <input class="pure-input-3-4" id="@{fieldName}" name="@{fieldName}" maxlength="40" placeholder="@Messages("form.label.name.placeholder")" required="" type="text" value="@{formData(fieldName).headOption}">
                 <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)}">
+                <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.label.description.placeholder")" type="text" value="@{formData(fieldDescription).headOption}">
                 <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")}">
+                <input id="@{fieldColour}" name="@{fieldColour}" required="" type="color" value="@{formData(fieldColour).headOption.getOrElse("#B48EAD")}">
                 <span class="pure-form-message" id="@{fieldColour}.help">@Messages("form.label.colour.help")</span>
                 @renderFormErrors(fieldColour, formErrors)
               </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	2025-01-16 13:03:36.594032983 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html	2025-01-16 13:03:36.598032982 +0000
@@ -16,7 +16,7 @@
   title: Option[String] = None,
   user: Account,
   project: Project
-)(formData: Map[String, String] = Map.empty,
+)(formData: Map[String, Chain[String]] = Map.empty.withDefaultValue(Chain.empty),
   formErrors: FormErrors = FormErrors.empty
 )
 @main(baseUri, lang)()(csrf, title, user.some) {
@@ -55,19 +55,19 @@
               <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)}">
+                <input id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData(fieldTitle).headOption}">
                 <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)}">
+                <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.milestone.description.placeholder")" type="text" value="@{formData(fieldDescription).headOption}">
                 <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)}">
+                <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData(fieldDueDate).headOption}">
                 <span class="pure-form-message" id="@{fieldDueDate}.help" style="margin-left: 13em;">@Messages("form.milestone.due-date.help")</span>
                 @renderFormErrors(fieldDueDate, formErrors)
               </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	2025-01-16 13:03:36.594032983 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html	2025-01-16 13:03:36.598032982 +0000
@@ -18,7 +18,7 @@
   title: Option[String] = None,
   user: Option[Account],
   project: Project
-)(formData: Map[String, String] = Map.empty,
+)(formData: Map[String, Chain[String]] = Map.empty.withDefaultValue(Chain.empty),
   formErrors: FormErrors = FormErrors.empty
 )
 @main(baseUri, lang)()(csrf, title, user) {
@@ -60,19 +60,19 @@
             <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)}">
+                <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).headOption}">
                 <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>
+                <textarea class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" placeholder="@Messages("form.milestone.description.placeholder")" rows="4" value="@{formData.get(fieldDescription).headOption}"></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)}">
+                <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData.get(fieldDueDate).headOption}">
                 <span class="pure-form-message" id="@{fieldDueDate}.help">@Messages("form.milestone.due-date.help")</span>
                 @renderFormErrors(fieldDueDate, formErrors)
               </div>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/internalServerError.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/internalServerError.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/internalServerError.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/internalServerError.scala.html	2025-01-16 13:03:36.598032982 +0000
@@ -0,0 +1,21 @@
+@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, user: Option[Account])
+@defining(lang.toLocale) { implicit locale =>
+@main(baseUri, lang)()(csrf, Messages("errors.internal-server-error.title").some, user) {
+  <div class="content">
+    <div class="pure-g">
+      <div class="pure-u-1-1 pure-u-md-1-1">
+        <div class="l-box">
+          <p class="alert alert-error">
+            <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+            <span class="sr-only">@Messages("errors.internal-server-error.title"):</span>
+            @Messages("errors.internal-server-error.message")
+          </p>
+        </div>
+      </div>
+    </div>
+  </div>
+}
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/unvalidatedAccount.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/unvalidatedAccount.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/unvalidatedAccount.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/unvalidatedAccount.scala.html	2025-01-16 13:03:36.598032982 +0000
@@ -0,0 +1,21 @@
+@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 =>
+  <div class="content">
+    <div class="pure-g">
+      <div class="pure-u-1-1 pure-u-md-1-1">
+        <div class="l-box">
+          <p class="alert alert-error">
+            <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+            <span class="sr-only">@Messages("global.error"):</span>
+            @Messages("errors.account.not-validated")
+          </p>
+        </div>
+      </div>
+    </div>
+  </div>
+}
+}
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	2025-01-16 13:03:36.594032983 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala	2025-01-16 13:03:36.598032982 +0000
@@ -44,7 +44,7 @@
     * @return
     *   Either the validated form as concrete type T or a list of form errors.
     */
-  def validate(data: Map[String, String]): ValidatedNec[FormErrors, T]
+  def validate(data: Map[String, Chain[String]]): ValidatedNec[FormErrors, T]
 
 }