~jan0sch/smederee

Showing details for patch 624ff2e48d10dc4e21d997f6ccb4cad7f673245d.
2023-05-23 (Tue), 7:42 PM - Jens Grassel - 624ff2e48d10dc4e21d997f6ccb4cad7f673245d

Tickets: Enable integration of ticket sites still hosted by the hub service.

This is a big patch including several changes:

- add a field `ticketsEnabled` to a `VcsRepository`
- add the column `tickets_enabled` to the database table for repository
  metadata
- adjust the related code in formes, routes and the database layer
- some cleanup in `DoobieVcsMetadataRepository`
- add new configuration options for ticket-service integration to the hub
  configuration used to generate links to the tickets
- add new configuration options for hub-service integration to the ticket
  service configuration used to generate links to everything related to hub
- replace tabs with spaces in the reference configuration of the ticket
  service
- add basic test for the ticket service configuration
- adjust templates regarding link rendering between the service urls
- add basic templates (main, navbar, etc.) to template path of ticket
  templates

Modify the actions for forms in the ticket templates to point to the hub
service base uri because the routes are still in the hub service. This will
need to be changed once they move into the ticket service running
seperately.
Summary of changes
5 files added
  • modules/hub/src/main/resources/db/migration/hub/V5__add_ticket_tracker.sql
  • modules/hub/src/main/twirl/de/smederee/tickets/views/main.scala.html
  • modules/hub/src/main/twirl/de/smederee/tickets/views/meta.scala.html
  • modules/hub/src/main/twirl/de/smederee/tickets/views/navbar.scala.html
  • modules/tickets/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala
32 files modified with 414 lines added and 197 lines removed
  • modules/hub/src/it/scala/de/smederee/hub/Generators.scala with 7 added and 6 removed lines
  • modules/hub/src/main/resources/messages.properties with 3 added and 0 removed lines
  • modules/hub/src/main/resources/reference.conf with 8 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala with 40 added and 10 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/EditVcsRepositoryForm.scala with 22 added and 9 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/NewVcsRepositoryForm.scala with 13 added and 6 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala with 3 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 63 added and 27 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala with 22 added and 3 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala with 17 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala with 8 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala with 8 added and 1 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/createRepository.scala.html with 6 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html with 6 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryBranches.scala.html with 2 added and 1 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html with 2 added and 1 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html with 2 added and 1 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html with 7 added and 1 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html with 2 added and 1 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html with 2 added and 1 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html with 4 added and 4 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 5 added and 5 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 5 added and 5 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/editTicket.scala.html with 4 added and 4 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html with 5 added and 4 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/showTicket.scala.html with 5 added and 5 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html with 4 added and 4 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/Generators.scala with 7 added and 6 removed lines
  • modules/tickets/src/main/resources/reference.conf with 86 added and 79 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala with 38 added and 2 removed lines
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-16 09:48:54.188767002 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala	2025-01-16 09:48:54.196767011 +0000
@@ -189,12 +189,13 @@
   val genValidVcsType = Gen.oneOf(VcsType.values.toList)
 
   val genValidVcsRepository: Gen[VcsRepository] = for {
-    name        <- genValidVcsRepositoryName
-    owner       <- genValidVcsRepositoryOwner
-    isPrivate   <- Gen.oneOf(List(false, true))
-    description <- Gen.alphaNumStr.map(VcsRepositoryDescription.from)
-    vcsType     <- genValidVcsType
-  } yield VcsRepository(name, owner, isPrivate, description, vcsType, None)
+    name           <- genValidVcsRepositoryName
+    owner          <- genValidVcsRepositoryOwner
+    isPrivate      <- Gen.oneOf(List(false, true))
+    description    <- Gen.alphaNumStr.map(VcsRepositoryDescription.from)
+    ticketsEnabled <- Gen.oneOf(List(false, true))
+    vcsType        <- genValidVcsType
+  } yield VcsRepository(name, owner, isPrivate, description, ticketsEnabled, vcsType, None)
 
   val genValidVcsRepositories: Gen[List[VcsRepository]] = Gen.nonEmptyListOf(genValidVcsRepository)
 
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/hub/V5__add_ticket_tracker.sql new-smederee/modules/hub/src/main/resources/db/migration/hub/V5__add_ticket_tracker.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/hub/V5__add_ticket_tracker.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/hub/V5__add_ticket_tracker.sql	2025-01-16 09:48:54.196767011 +0000
@@ -0,0 +1,4 @@
+ALTER TABLE "hub"."repositories"
+  ADD COLUMN "tickets_enabled" BOOLEAN NOT NULL DEFAULT FALSE;
+
+COMMENT ON COLUMN "hub"."repositories"."tickets_enabled" IS 'A flag indicating if ticket tracking support via the ticket service is enabled for this repository.';
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 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/resources/messages.properties	2025-01-16 09:48:54.196767011 +0000
@@ -34,6 +34,8 @@
 form.create-repo.description=Description
 form.create-repo.description.placeholder=
 form.create-repo.description.help=An optional short description of you repo / project.
+form.create-repo.tickets-enabled=Ticket tracking
+form.create-repo.tickets-enabled.help=Enable ticket tracking support to be able to use tickets with labels and milestones for organising the development process if needed.
 form.create-repo.website=Website
 form.create-repo.website.placeholder=https://example.com
 form.create-repo.website.help=An optional URI pointing to the website of your project.
@@ -207,6 +209,7 @@
 repository.menu.labels=Labels
 repository.menu.milestones=Milestones
 repository.menu.overview=Overview
+repository.menu.tickets=Tickets
 repository.menu.website=Website
 repository.menu.website.tooltip=Click here to open the project website ({0}) in a new tab or window.
 
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-16 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/resources/reference.conf	2025-01-16 09:48:54.196767011 +0000
@@ -164,5 +164,13 @@
     signup {
       enabled = true
     }
+
+    # Configuration regarding the integration with the ticket service.
+    ticket-integration {
+      enabled = false
+      # The base URI used to build links to the ticket service.
+      base-uri = "http://localhost:8081"
+      base-uri = ${?SMEDEREE_TICKET_BASE_URI}
+    }
   }
 }
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-16 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala	2025-01-16 09:48:54.200767016 +0000
@@ -197,6 +197,8 @@
   *   The configuration for the signup / registration feature.
   * @param ssh
   *   Settings for the embedded SSH server component.
+  * @param tickets
+  *   Configuration regarding the integration with the hub service.
   */
 final case class ServiceConfig(
     host: Host,
@@ -210,7 +212,8 @@
     email: EmailMiddlewareConfiguration,
     external: ExternalUrlConfiguration,
     signup: SignupConfiguration,
-    ssh: SshServerConfiguration
+    ssh: SshServerConfiguration,
+    tickets: TicketIntegrationConfiguration
 )
 
 object ServiceConfig {
@@ -237,7 +240,7 @@
     ConfigReader.forProduct4("host", "path", "port", "scheme")(ExternalUrlConfiguration.apply)
 
   given ConfigReader[ServiceConfig] =
-    ConfigReader.forProduct12(
+    ConfigReader.forProduct13(
       "host",
       "port",
       "csrf-key-file",
@@ -249,7 +252,8 @@
       "email",
       "external",
       "signup",
-      "ssh"
+      "ssh",
+      "ticket-integration"
     )(ServiceConfig.apply)
 }
 
@@ -370,3 +374,18 @@
   given ConfigReader[SignupConfiguration] = ConfigReader.forProduct1("enabled")(SignupConfiguration.apply)
 
 }
+
+/** Configuration regarding the integration with the ticket service.
+  *
+  * @param baseUri
+  *   The base URI used to build links to the ticket service.
+  * @param enabled
+  *   A flag indicating if the ticket service integration is enabled.
+  */
+final case class TicketIntegrationConfiguration(baseUri: Uri, enabled: Boolean)
+
+object TicketIntegrationConfiguration {
+  given ConfigReader[Uri] = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption)
+  given ConfigReader[TicketIntegrationConfiguration] =
+    ConfigReader.forProduct2("base-uri", "enabled")(TicketIntegrationConfiguration.apply)
+}
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-16 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-01-16 09:48:54.200767016 +0000
@@ -50,6 +50,7 @@
           "accounts".email AS owner_email,
           "repos".is_private AS is_private,
           "repos".description AS description,
+          "repos".tickets_enabled AS tickets_enabled,
           "repos".vcs_type AS vcs_type,
           "repos".website AS website
         FROM "hub"."repositories" AS "repos"
@@ -57,15 +58,41 @@
         ON "repos".owner = "accounts".uid"""
 
   override def createFork(source: VcsRepositoryId, target: VcsRepositoryId): F[Int] =
-    sql"""INSERT INTO "hub"."forks" (original_repo, forked_repo) VALUES ($source, $target)""".update.run.transact(tx)
+    sql"""INSERT INTO "hub"."forks" (
+            original_repo,
+            forked_repo
+          ) VALUES (
+            $source,
+            $target
+          )""".update.run.transact(tx)
 
   override def createVcsRepository(repository: VcsRepository): F[Int] =
-    sql"""INSERT INTO "hub"."repositories" (name, owner, is_private, description, vcs_type, website, created_at, updated_at) VALUES (${repository.name}, ${repository.owner.uid}, ${repository.isPrivate}, ${repository.description}, ${repository.vcsType}, ${repository.website}, NOW(), NOW())""".update.run
-      .transact(tx)
+    sql"""INSERT INTO "hub"."repositories" (
+            name,
+            owner,
+            is_private,
+            description,
+            tickets_enabled,
+            vcs_type,
+            website,
+            created_at,
+            updated_at
+          ) VALUES (
+            ${repository.name},
+            ${repository.owner.uid},
+            ${repository.isPrivate},
+            ${repository.description},
+            ${repository.ticketsEnabled},
+            ${repository.vcsType},
+            ${repository.website},
+            NOW(),
+            NOW()
+          )""".update.run.transact(tx)
 
   override def deleteVcsRepository(repository: VcsRepository): F[Int] =
-    sql"""DELETE FROM "hub"."repositories" WHERE owner = ${repository.owner.uid} AND name = ${repository.name}""".update.run
-      .transact(tx)
+    sql"""DELETE FROM "hub"."repositories"
+          WHERE owner = ${repository.owner.uid}
+          AND name = ${repository.name}""".update.run.transact(tx)
 
   override def findVcsRepository(
       owner: VcsRepositoryOwner,
@@ -145,10 +172,13 @@
   }
 
   override def updateVcsRepository(repository: VcsRepository): F[Int] =
-    sql"""UPDATE "hub"."repositories" SET is_private = ${repository.isPrivate}, 
-    description = ${repository.description}, 
-    website = ${repository.website}, 
-    updated_at = NOW() 
-    WHERE owner = ${repository.owner.uid} AND name = ${repository.name}""".update.run.transact(tx)
+    sql"""UPDATE "hub"."repositories"
+            SET is_private = ${repository.isPrivate},
+            description = ${repository.description},
+            tickets_enabled = ${repository.ticketsEnabled},
+            website = ${repository.website},
+            updated_at = NOW()
+          WHERE owner = ${repository.owner.uid}
+          AND name = ${repository.name}""".update.run.transact(tx)
 
 }
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/EditVcsRepositoryForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/EditVcsRepositoryForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/EditVcsRepositoryForm.scala	2025-01-16 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/EditVcsRepositoryForm.scala	2025-01-16 09:48:54.200767016 +0000
@@ -33,6 +33,8 @@
   *   permissions.
   * @param description
   *   An optional short text description of the repository.
+  * @param ticketsEnabled
+  *   A flag indicating if ticket tracking support via the ticket service is enabled for this repository.
   * @param website
   *   An optional uri pointing to a website related to the repository / project.
   */
@@ -40,14 +42,16 @@
     name: VcsRepositoryName,
     isPrivate: Boolean,
     description: Option[VcsRepositoryDescription],
+    ticketsEnabled: Boolean,
     website: Option[Uri]
 )
 
 object EditVcsRepositoryForm extends FormValidator[EditVcsRepositoryForm] {
-  val fieldDescription: FormField = FormField("description")
-  val fieldIsPrivate: FormField   = FormField("is_private")
-  val fieldName: FormField        = FormField("name")
-  val fieldWebsite: FormField     = FormField("website")
+  val fieldDescription: FormField    = FormField("description")
+  val fieldIsPrivate: FormField      = FormField("is_private")
+  val fieldName: FormField           = FormField("name")
+  val fieldTicketsEnabled: FormField = FormField("tickets_enabled")
+  val fieldWebsite: FormField        = FormField("website")
 
   /** Create a form for editing a vcs repository filled with the data from the given repository.
     *
@@ -57,7 +61,7 @@
     *   A edit vcs repository form filled with the data from the repository.
     */
   def fromVcsRepository(repo: VcsRepository): EditVcsRepositoryForm =
-    EditVcsRepositoryForm(repo.name, repo.isPrivate, repo.description, repo.website)
+    EditVcsRepositoryForm(repo.name, repo.isPrivate, repo.description, repo.ticketsEnabled, repo.website)
 
   override def validate(data: Map[String, String]): ValidatedNec[FormErrors, EditVcsRepositoryForm] = {
     val name = data
@@ -79,6 +83,8 @@
             .fold(FormFieldError("Invalid repository description!").invalidNec)(descr => Option(descr).validNec)
       }
       .leftMap(es => NonEmptyChain.of(Map(fieldDescription -> es.toList)))
+    val ticketsEnabledFlag: ValidatedNec[FormErrors, Boolean] =
+      data.get(fieldTicketsEnabled).fold(false.validNec)(s => s.matches("true").validNec)
     val website = data
       .get(fieldWebsite)
       .fold(Option.empty[Uri].validNec) { s =>
@@ -96,8 +102,9 @@
             }
       }
       .leftMap(es => NonEmptyChain.of(Map(fieldWebsite -> es.toList)))
-    (name, privateFlag, description, website).mapN { case (validName, isPrivate, validDescription, validWebsite) =>
-      EditVcsRepositoryForm(validName, isPrivate, validDescription, validWebsite)
+    (name, privateFlag, description, ticketsEnabledFlag, website).mapN {
+      case (validName, isPrivate, validDescription, ticketsEnabled, validWebsite) =>
+        EditVcsRepositoryForm(validName, isPrivate, validDescription, ticketsEnabled, validWebsite)
     }
   }
 
@@ -115,9 +122,15 @@
           "true"
         else
           "false"
+      val ticketsEnabled =
+        if (form.ticketsEnabled)
+          "true"
+        else
+          "false"
       val formData = Map(
-        EditVcsRepositoryForm.fieldName.toString      -> form.name.toString,
-        EditVcsRepositoryForm.fieldIsPrivate.toString -> isPrivate
+        EditVcsRepositoryForm.fieldName.toString           -> form.name.toString,
+        EditVcsRepositoryForm.fieldIsPrivate.toString      -> isPrivate,
+        EditVcsRepositoryForm.fieldTicketsEnabled.toString -> ticketsEnabled
       )
       val description = form.description.fold(Map.empty)(description =>
         Map(EditVcsRepositoryForm.fieldDescription.toString -> description.toString)
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/NewVcsRepositoryForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/NewVcsRepositoryForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/NewVcsRepositoryForm.scala	2025-01-16 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/NewVcsRepositoryForm.scala	2025-01-16 09:48:54.200767016 +0000
@@ -33,6 +33,8 @@
   *   permissions.
   * @param description
   *   An optional short text description of the repository.
+  * @param ticketsEnabled
+  *   A flag indicating if ticket tracking support via the ticket service is enabled for this repository.
   * @param website
   *   An optional uri pointing to a website related to the repository / project.
   */
@@ -40,14 +42,16 @@
     name: VcsRepositoryName,
     isPrivate: Boolean,
     description: Option[VcsRepositoryDescription],
+    ticketsEnabled: Boolean,
     website: Option[Uri]
 )
 
 object NewVcsRepositoryForm extends FormValidator[NewVcsRepositoryForm] {
-  val fieldDescription: FormField = FormField("description")
-  val fieldIsPrivate: FormField   = FormField("is_private")
-  val fieldName: FormField        = FormField("name")
-  val fieldWebsite: FormField     = FormField("website")
+  val fieldDescription: FormField    = FormField("description")
+  val fieldIsPrivate: FormField      = FormField("is_private")
+  val fieldName: FormField           = FormField("name")
+  val fieldTicketsEnabled: FormField = FormField("tickets_enabled")
+  val fieldWebsite: FormField        = FormField("website")
 
   override def validate(data: Map[String, String]): ValidatedNec[FormErrors, NewVcsRepositoryForm] = {
     val name = data
@@ -69,6 +73,8 @@
             .fold(FormFieldError("Invalid repository description!").invalidNec)(descr => Option(descr).validNec)
       }
       .leftMap(es => NonEmptyChain.of(Map(fieldDescription -> es.toList)))
+    val ticketsEnabledFlag: ValidatedNec[FormErrors, Boolean] =
+      data.get(fieldTicketsEnabled).fold(false.validNec)(s => s.matches("true").validNec)
     val website = data
       .get(fieldWebsite)
       .fold(Option.empty[Uri].validNec) { s =>
@@ -86,8 +92,9 @@
             }
       }
       .leftMap(es => NonEmptyChain.of(Map(fieldWebsite -> es.toList)))
-    (name, privateFlag, description, website).mapN { case (validName, isPrivate, validDescription, validWebsite) =>
-      NewVcsRepositoryForm(validName, isPrivate, validDescription, validWebsite)
+    (name, privateFlag, description, ticketsEnabledFlag, website).mapN {
+      case (validName, isPrivate, validDescription, ticketsEnabled, validWebsite) =>
+        NewVcsRepositoryForm(validName, isPrivate, validDescription, ticketsEnabled, validWebsite)
     }
   }
 }
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-16 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-16 09:48:54.200767016 +0000
@@ -64,11 +64,12 @@
 ) extends Http4sDsl[F] {
   private val log = LoggerFactory.getLogger(getClass)
 
-  private val MaximumFileSize = configuration.renderMaximumFileSize
-  private val createRepoPath  = uri"/repo/create"
-  private val darcsConfig     = configuration.darcs
-  private val linkConfig      = configuration.external
-  private val sshConfig       = configuration.ssh
+  private val MaximumFileSize     = configuration.renderMaximumFileSize
+  private val createRepoPath      = uri"/repo/create"
+  private val darcsConfig         = configuration.darcs
+  private val linkConfig          = configuration.external
+  private val linkToTicketService = if (configuration.tickets.enabled) configuration.tickets.baseUri.some else None
+  private val sshConfig           = configuration.ssh
 
   // The base URI for our site which that be passed into some templates which create links themselfes.
   private val baseUri = linkConfig.createFullUri(Uri())
@@ -337,6 +338,7 @@
             views.html.showRepositoryBranches(baseUri, lang = language)(
               actionBaseUri,
               csrf,
+              linkToTicketService,
               s"Smederee/~$repositoryOwnerName/$repositoryName".some,
               user
             )(repo, branches)
@@ -427,6 +429,7 @@
             views.html.showRepositoryOverview(baseUri, lang = language)(
               actionBaseUri,
               csrf,
+              linkToTicketService,
               s"Smederee/~$repositoryOwnerName/$repositoryName".some,
               user
             )(
@@ -570,7 +573,8 @@
                 views.html.showRepositoryFiles(baseUri, lang = language)(
                   actionBaseUri,
                   csrf,
-                  Option(goBackUri),
+                  goBackUri.some,
+                  linkToTicketService,
                   s"Smederee/~$repositoryOwnerName/$repositoryName".some,
                   user
                 )(fileContent, listing, repositoryBaseUri, repo, branches)
@@ -666,7 +670,8 @@
             views.html.showRepositoryHistory(baseUri, lang = language)(
               actionBaseUri,
               csrf,
-              Option(goBackUri),
+              goBackUri.some,
+              linkToTicketService,
               s"Smederee - History of ~$repositoryOwnerName/$repositoryName".some,
               user
             )(patches, next, repositoryBaseUri, repo, branches)
@@ -735,7 +740,13 @@
         case Some(repo) =>
           Ok(
             views.html
-              .showRepositoryPatch(baseUri, lang = language)(actionBaseUri, csrf, patch.map(_.name.toString), user)(
+              .showRepositoryPatch(baseUri, lang = language)(
+                actionBaseUri,
+                csrf,
+                linkToTicketService,
+                patch.map(_.name.toString),
+                user
+              )(
                 patch,
                 cleanedHtmlPatchDetails,
                 repo,
@@ -990,14 +1001,14 @@
                     FormErrors.fromNec(es)
                   )
               )
-            case Validated.Valid(newVcsRepository) =>
+            case Validated.Valid(newVcsRepositoryForm) =>
               for {
                 directory <- Sync[F].delay(
                   Paths.get(darcsConfig.repositoriesDirectory.toPath.toString, user.name.toString)
                 )
                 repoInDb <- vcsMetadataRepo.findVcsRepository(
                   user.toVcsRepositoryOwner,
-                  newVcsRepository.name
+                  newVcsRepositoryForm.name
                 )
                 output <- repoInDb match {
                   case None =>
@@ -1010,20 +1021,24 @@
                           val _ = Files.createDirectories(directory)
                         }
                       }
-                      repoMetadata = VcsRepository(
-                        newVcsRepository.name,
+                      newRepo = VcsRepository(
+                        newVcsRepositoryForm.name,
                         user.toVcsRepositoryOwner,
-                        newVcsRepository.isPrivate,
-                        newVcsRepository.description,
+                        newVcsRepositoryForm.isPrivate,
+                        newVcsRepositoryForm.description,
+                        newVcsRepositoryForm.ticketsEnabled,
                         VcsType.Darcs,
-                        newVcsRepository.website
+                        newVcsRepositoryForm.website
                       )
-                      output <- darcs.initialize(directory)(newVcsRepository.name.toString)(Chain.empty)
+                      output <- darcs.initialize(directory)(newRepo.name.toString)(Chain.empty)
                       _ <-
                         if (output.exitValue === 0)
-                          vcsMetadataRepo.createVcsRepository(repoMetadata) *> ticketsProjectRepo.createProject(
-                            repoMetadata.convert
-                          )
+                          for {
+                            written <- vcsMetadataRepo.createVcsRepository(newRepo)
+                            _ <- Option(newRepo)
+                              .filter(_.ticketsEnabled)
+                              .traverse(repo => ticketsProjectRepo.createProject(repo.convert))
+                          } yield written
                         else
                           Sync[F].pure(0) // Do not create DB entry if darcs init failed!
                     } yield output
@@ -1041,7 +1056,7 @@
                     for {
                       _ <- Sync[F].delay(
                         log.error(
-                          s"Error creating the repository ${newVcsRepository.name} in directory $directory: ${output.stderr.toList.mkString}"
+                          s"Error creating the repository ${newVcsRepositoryForm.name} in directory $directory: ${output.stderr.toList.mkString}"
                         )
                       )
                       resp <- InternalServerError(
@@ -1171,15 +1186,36 @@
                         branches
                       )(formData, FormErrors.fromNec(errors))
                     )
-                  case Validated.Valid(updatedVcsRepository) =>
-                    val repoMetadata = repo.copy(
-                      isPrivate = updatedVcsRepository.isPrivate,
-                      description = updatedVcsRepository.description,
-                      website = updatedVcsRepository.website
+                  case Validated.Valid(updatedVcsRepositoryForm) =>
+                    val updatedRepo = repo.copy(
+                      isPrivate = updatedVcsRepositoryForm.isPrivate,
+                      description = updatedVcsRepositoryForm.description,
+                      ticketsEnabled = updatedVcsRepositoryForm.ticketsEnabled,
+                      website = updatedVcsRepositoryForm.website
                     )
+                    // If the repo switched from tickets disabled to enabled, we create a ticket tracker.
+                    val createTicketTracker =
+                      updatedRepo.ticketsEnabled && updatedRepo.ticketsEnabled =!= repo.ticketsEnabled
+                    // If the repo switched from tickets enabled to disabled, we delete the ticket tracker.
+                    val deleteTicketTracker =
+                      !updatedRepo.ticketsEnabled && updatedRepo.ticketsEnabled =!= repo.ticketsEnabled
                     for {
-                      _    <- vcsMetadataRepo.updateVcsRepository(repoMetadata)
-                      _    <- ticketsProjectRepo.updateProject(repoMetadata.convert)
+                      _ <- vcsMetadataRepo.updateVcsRepository(updatedRepo)
+                      _ <-
+                        if (createTicketTracker)
+                          ticketsProjectRepo.createProject(updatedRepo.convert)
+                        else
+                          Sync[F].pure(0)
+                      _ <-
+                        if (deleteTicketTracker)
+                          ticketsProjectRepo.deleteProject(updatedRepo.convert)
+                        else
+                          Sync[F].pure(0)
+                      _ <-
+                        if (!createTicketTracker && !deleteTicketTracker && updatedRepo.ticketsEnabled)
+                          ticketsProjectRepo.updateProject(updatedRepo.convert)
+                        else
+                          Sync[F].pure(0)
                       resp <- SeeOther(Location(repoUri))
                     } yield resp
                 }
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-16 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala	2025-01-16 09:48:54.200767016 +0000
@@ -531,6 +531,8 @@
   *   permissions.
   * @param description
   *   An optional short text description of the repository.
+  * @param ticketsEnabled
+  *   A flag indicating if ticket tracking support via the ticket service is enabled for this repository.
   * @param vcsType
   *   The type of the underlying DVCS that manages the repository.
   * @param website
@@ -541,6 +543,7 @@
     owner: VcsRepositoryOwner,
     isPrivate: Boolean,
     description: Option[VcsRepositoryDescription],
+    ticketsEnabled: Boolean,
     vcsType: VcsType,
     website: Option[Uri]
 )
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 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala	2025-01-16 09:48:54.200767016 +0000
@@ -55,7 +55,8 @@
 
   given CsrfProtectionConfiguration = configuration.csrfProtection
 
-  val linkConfig = configuration.externalUrl
+  private val linkToHubService = configuration.hub.baseUri
+  private val linkConfig       = configuration.externalUrl
 
   /** Logic for rendering a list of all labels for a project and optionally management functionality.
     *
@@ -94,6 +95,7 @@
               views.html.editLabels(lang = language)(
                 projectBaseUri.addSegment("labels"),
                 csrf,
+                linkToHubService,
                 labels,
                 projectBaseUri,
                 "Manage your project labels.".some,
@@ -179,6 +181,7 @@
                         views.html.editLabels(lang = language)(
                           projectBaseUri.addSegment("labels"),
                           csrf,
+                          linkToHubService,
                           labels.getOrElse(List.empty),
                           projectBaseUri,
                           "Manage your project labels.".some,
@@ -200,6 +203,7 @@
                               views.html.editLabels(lang = language)(
                                 projectBaseUri.addSegment("labels"),
                                 csrf,
+                                linkToHubService,
                                 labels.getOrElse(List.empty),
                                 projectBaseUri,
                                 "Manage your project labels.".some,
@@ -347,6 +351,7 @@
                         views.html.editLabel(lang = language)(
                           actionUri,
                           csrf,
+                          linkToHubService,
                           label,
                           projectBaseUri,
                           s"Edit label ${label.name}".some,
@@ -376,6 +381,7 @@
                               views.html.editLabel(lang = language)(
                                 actionUri,
                                 csrf,
+                                linkToHubService,
                                 label,
                                 projectBaseUri,
                                 s"Edit label ${label.name}".some,
@@ -433,7 +439,16 @@
               formData  <- Sync[F].delay(LabelForm.fromLabel(label))
               resp <- Ok(
                 views.html
-                  .editLabel()(actionUri, csrf, label, projectBaseUri, s"Edit label ${label.name}".some, user, project)(
+                  .editLabel()(
+                    actionUri,
+                    csrf,
+                    linkToHubService,
+                    label,
+                    projectBaseUri,
+                    s"Edit label ${label.name}".some,
+                    user,
+                    project
+                  )(
                     formData.toMap
                   )
               )
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 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2025-01-16 09:48:54.200767016 +0000
@@ -53,7 +53,8 @@
 
   given CsrfProtectionConfiguration = configuration.csrfProtection
 
-  val linkConfig = configuration.externalUrl
+  private val linkToHubService = configuration.hub.baseUri
+  private val linkConfig       = configuration.externalUrl
 
   /** Logic for rendering a list of all milestones for a project and optionally management functionality.
     *
@@ -91,6 +92,7 @@
               views.html.editMilestones(lang = language)(
                 projectBaseUri.addSegment("milestones"),
                 csrf,
+                linkToHubService,
                 milestones,
                 projectBaseUri,
                 "Manage your project milestones.".some,
@@ -175,6 +177,7 @@
                       views.html.editMilestones(lang = language)(
                         projectBaseUri.addSegment("milestones"),
                         csrf,
+                        linkToHubService,
                         milestones.getOrElse(List.empty),
                         projectBaseUri,
                         "Manage your project milestones.".some,
@@ -197,6 +200,7 @@
                             views.html.editMilestones(lang = language)(
                               projectBaseUri.addSegment("milestones"),
                               csrf,
+                              linkToHubService,
                               milestones.getOrElse(List.empty),
                               projectBaseUri,
                               "Manage your project milestones.".some,
@@ -337,6 +341,7 @@
                       views.html.editMilestone(lang = language)(
                         actionUri,
                         csrf,
+                        linkToHubService,
                         milestone,
                         projectBaseUri,
                         s"Edit milestone ${milestone.title}".some,
@@ -366,6 +371,7 @@
                             views.html.editMilestone(lang = language)(
                               actionUri,
                               csrf,
+                              linkToHubService,
                               milestone,
                               projectBaseUri,
                               s"Edit milestone ${milestone.title}".some,
@@ -420,6 +426,7 @@
                 views.html.editMilestone(lang = language)(
                   actionUri,
                   csrf,
+                  linkToHubService,
                   milestone,
                   projectBaseUri,
                   s"Edit milestone ${milestone.title}".some,
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 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2025-01-16 09:48:54.200767016 +0000
@@ -64,7 +64,8 @@
 
   given CsrfProtectionConfiguration = configuration.csrfProtection
 
-  val linkConfig = configuration.externalUrl
+  private val linkToHubService = configuration.hub.baseUri
+  private val linkConfig       = configuration.externalUrl
 
   /** 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.
@@ -147,6 +148,7 @@
               views.html.showTicket(lang = language)(
                 projectBaseUri.addSegment("tickets"),
                 csrf,
+                linkToHubService,
                 labels,
                 milestones,
                 ticket,
@@ -201,6 +203,7 @@
               views.html.showTickets(lang = language)(
                 projectBaseUri.addSegment("tickets"),
                 csrf,
+                linkToHubService,
                 tickets,
                 projectBaseUri,
                 "Manage your project tickets.".some,
@@ -248,6 +251,7 @@
                         views.html.createTicket(lang = language)(
                           projectBaseUri.addSegment("tickets"),
                           csrf,
+                          linkToHubService,
                           labels,
                           milestones,
                           projectBaseUri,
@@ -333,6 +337,7 @@
                         views.html.createTicket(lang = language)(
                           projectBaseUri.addSegment("tickets"),
                           csrf,
+                          linkToHubService,
                           labels,
                           milestones,
                           projectBaseUri,
@@ -413,6 +418,7 @@
                   views.html.createTicket(lang = language)(
                     projectBaseUri.addSegment("tickets"),
                     csrf,
+                    linkToHubService,
                     labels,
                     milestones,
                     projectBaseUri,
@@ -467,6 +473,7 @@
                   views.html.editTicket(lang = language)(
                     projectBaseUri.addSegment("tickets"),
                     csrf,
+                    linkToHubService,
                     labels,
                     milestones,
                     projectBaseUri,
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-16 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/createRepository.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -37,6 +37,12 @@
                   @renderFormErrors(fieldIsPrivate, formErrors)
                 </div>
                 <div class="pure-control-group">
+                  <label for="@{fieldTicketsEnabled}">@Messages("form.create-repo.tickets-enabled")</label>
+                  <input id="@{fieldTicketsEnabled}" name="@{fieldTicketsEnabled}" type="checkbox" value="true" @if(formData.get(fieldTicketsEnabled).map(_ === "true").getOrElse(false)){ checked="" } else { }>
+                  <span class="pure-form-message-inline" id="@{fieldTicketsEnabled}.help">@Messages("form.create-repo.tickets-enabled.help")</span>
+                  @renderFormErrors(fieldTicketsEnabled, formErrors)
+                </div>
+                <div class="pure-control-group">
                   <label for="@{fieldDescription}">@Messages("form.create-repo.description")</label>
                   <textarea class="pure-input-1-2" id="@{fieldDescription}" name="@{fieldDescription}" placeholder="@Messages("form.create-repo.description.placeholder")" maxlength="254" rows="3">@{formData.get(fieldDescription)}</textarea>
                   <span class="pure-form-message" id="@{fieldDescription}.help">@Messages("form.create-repo.description.help")</span>
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-16 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -59,6 +59,12 @@
                   @renderFormErrors(fieldIsPrivate, formErrors)
                 </div>
                 <div class="pure-control-group">
+                  <label for="@{fieldTicketsEnabled}">@Messages("form.create-repo.tickets-enabled")</label>
+                  <input id="@{fieldTicketsEnabled}" name="@{fieldTicketsEnabled}" type="checkbox" value="true" @if(formData.get(fieldTicketsEnabled).map(_ === "true").getOrElse(false)){ checked="" } else { }>
+                  <span class="pure-form-message-inline" id="@{fieldTicketsEnabled}.help">@Messages("form.create-repo.tickets-enabled.help")</span>
+                  @renderFormErrors(fieldTicketsEnabled, formErrors)
+                </div>
+                <div class="pure-control-group">
                   <label for="@{fieldDescription}">@Messages("form.edit-repo.description")</label>
                   <textarea class="pure-input-1-2" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" rows="3">@{formData.get(fieldDescription)}</textarea>
                   <span class="pure-form-message" id="@{fieldDescription}.help">@Messages("form.edit-repo.description.help")</span>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryBranches.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryBranches.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryBranches.scala.html	2025-01-16 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryBranches.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -4,6 +4,7 @@
   lang: LanguageCode = LanguageCode("en")
 )(actionBaseUri: Uri,
   csrf: Option[CsrfToken] = None,
+  linkToTicketService: Option[Uri] = None,
   title: Option[String] = None,
   user: Option[Account]
 )(vcsRepository: VcsRepository,
@@ -16,7 +17,7 @@
       <div class="pure-u-1">
         <div class="l-box-left-right">
           <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
-          @showRepositoryMenu(baseUri)(actionBaseUri.addSegment("branches").some, vcsRepositoryBranches.size, actionBaseUri, user, vcsRepository)
+          @showRepositoryMenu(baseUri, linkToTicketService)(actionBaseUri.addSegment("branches").some, vcsRepositoryBranches.size, actionBaseUri, user, vcsRepository)
           <div class="repo-summary-description">
             @Messages("repository.branches.summary", vcsRepositoryBranches.size)
           </div>
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-16 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -7,6 +7,7 @@
 )(actionBaseUri: Uri,
   csrf: Option[CsrfToken] = None,
   goBackUri: Option[Uri] = None,
+  linkToTicketService: Option[Uri] = None,
   title: Option[String] = None,
   user: Option[Account]
 )(fileContent: List[String],
@@ -22,7 +23,7 @@
       <div class="pure-u-1-1 pure-u-md-1-1">
         <div class="l-box-left-right">
           <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
-          @showRepositoryMenu(baseUri)(repositoryBaseUri.addSegment("files").some, vcsRepositoryBranches.size, repositoryBaseUri, user, vcsRepository)
+          @showRepositoryMenu(baseUri, linkToTicketService)(repositoryBaseUri.addSegment("files").some, vcsRepositoryBranches.size, repositoryBaseUri, user, vcsRepository)
           <div class="repo-summary-description">
             <code>@{actionBaseUri.path.toString.replaceFirst("/files", "")}</code>
           </div>
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-16 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -5,6 +5,7 @@
 )(actionBaseUri: Uri,
   csrf: Option[CsrfToken] = None,
   goBackUri: Option[Uri] = None,
+  linkToTicketService: Option[Uri] = None,
   title: Option[String] = None,
   user: Option[Account]
 )(history: List[VcsRepositoryPatchMetadata],
@@ -20,7 +21,7 @@
       <div class="pure-u-1-1 pure-u-md-1-1">
         <div class="l-box-left-right">
           <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
-          @showRepositoryMenu(baseUri)(repositoryBaseUri.addSegment("history").some, vcsRepositoryBranches.size, repositoryBaseUri, user, vcsRepository)
+          @showRepositoryMenu(baseUri, linkToTicketService)(repositoryBaseUri.addSegment("history").some, vcsRepositoryBranches.size, repositoryBaseUri, user, vcsRepository)
           <div class="repo-summary-description">
             @if(history.isEmpty) {
               @Messages("repository.changes.description.empty")
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-16 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -1,6 +1,7 @@
 @import de.smederee.hub._
 
-@(baseUri: Uri
+@(baseUri: Uri,
+  linkToTicketService: Option[Uri] = None,
 )(activeUri: Option[Uri],
   branches: Int,
   repositoryBaseUri: Uri,
@@ -15,6 +16,11 @@
     @defining(repositoryBaseUri.addSegment("files")) { uri =>
     <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("folder") @Messages("repository.menu.files")</a></li>
     }
+    @for(ticketUri <- linkToTicketService.filter(_ => vcsRepository.ticketsEnabled)) {
+      @defining(ticketUri.addPath(repositoryBaseUri.path.toString).addSegment("tickets")) { uri =>
+      <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("crosshair") @Messages("repository.menu.tickets")</a></li>
+      }
+    }
     @defining(repositoryBaseUri.addSegment("history")) { uri =>
     <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("list") @Messages("repository.menu.changes")</a></li>
     }
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-16 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -4,6 +4,7 @@
   lang: LanguageCode = LanguageCode("en")
 )(actionBaseUri: Uri,
   csrf: Option[CsrfToken] = None,
+  linkToTicketService: Option[Uri] = None,
   title: Option[String] = None,
   user: Option[Account]
 )(vcsRepository: VcsRepository,
@@ -21,7 +22,7 @@
       <div class="pure-u-1">
         <div class="l-box-left-right">
           <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
-          @showRepositoryMenu(baseUri)(actionBaseUri.some, vcsRepositoryBranches.size, actionBaseUri, user, vcsRepository)
+          @showRepositoryMenu(baseUri, linkToTicketService)(actionBaseUri.some, vcsRepositoryBranches.size, actionBaseUri, user, vcsRepository)
           <div class="repo-summary-description">
             <strong>@Messages("repository.description.title")</strong> @vcsRepository.description
           </div>
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-16 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -4,6 +4,7 @@
   lang: LanguageCode = LanguageCode("en")
 )(actionBaseUri: Uri,
   csrf: Option[CsrfToken] = None,
+  linkToTicketService: Option[Uri] = None,
   title: Option[String] = None,
   user: Option[Account]
 )(patch: Option[VcsRepositoryPatchMetadata],
@@ -18,7 +19,7 @@
       <div class="pure-u-1-1 pure-u-md-1-1">
         <div class="l-box-left-right">
           <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
-          @showRepositoryMenu(baseUri)(actionBaseUri.addSegment("history").some, vcsRepositoryBranches.size, actionBaseUri, user, vcsRepository)
+          @showRepositoryMenu(baseUri, linkToTicketService)(actionBaseUri.addSegment("history").some, vcsRepositoryBranches.size, actionBaseUri, user, vcsRepository)
           <div class="repo-summary-description">
             @for(patch <- patch) {
               @Messages("repository.changes.patch.description", patch.hash.toString)
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 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -1,5 +1,4 @@
 @import de.smederee.hub.Account
-@import de.smederee.hub.views.html.main
 @import de.smederee.tickets.TicketForm._
 @import de.smederee.tickets._
 @import de.smederee.tickets.forms._
@@ -10,6 +9,7 @@
   lang: LanguageCode = LanguageCode("en")
 )(action: Uri,
   csrf: Option[CsrfToken] = None,
+  linkToHubService: Uri,
   labels: List[Label] = Nil,
   milestones: List[Milestone] = Nil,
   projectBaseUri: Uri,
@@ -42,8 +42,8 @@
   <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)
+        <h2><a href="@{linkToHubService.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2>
+        @showProjectMenu(baseUri, linkToHubService)(action.some, projectBaseUri, user, project)
         <div class="project-summary-description">
           @Messages("project.tickets.view.title")
         </div>
@@ -66,7 +66,7 @@
           }
         </div>
         <div class="edit-tickets-form">
-          <form action="@projectBaseUri.addSegment("tickets")" class="pure-form pure-form-stacked" method="POST" accept-charset="UTF-8">
+          <form action="@{linkToHubService.addPath(projectBaseUri.path.toString).addSegment("tickets")}" class="pure-form pure-form-stacked" method="POST" accept-charset="UTF-8">
             <fieldset class="pure-group">
               <div class="pure-g">
                 <div class="pure-u-18-24 pure-u-md-18-24">
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 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -1,5 +1,4 @@
 @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._
@@ -11,6 +10,7 @@
   lang: LanguageCode = LanguageCode("en")
 )(action: Uri,
   csrf: Option[CsrfToken] = None,
+  linkToHubService: Uri,
   label: Label,
   projectBaseUri: Uri,
   title: Option[String] = None,
@@ -25,8 +25,8 @@
   <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)
+        <h2><a href="@{linkToHubService.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2>
+        @showProjectMenu(baseUri, linkToHubService)(projectBaseUri.addSegment("labels").some, projectBaseUri, user.some, project)
         <div class="project-summary-description">
           @Messages("project.labels.edit.title")
         </div>
@@ -50,7 +50,7 @@
           }
         </div>
         <div class="edit-labels-form">
-          <form action="@action" class="pure-form pure-form-aligned" method="POST" accept-charset="UTF-8">
+          <form action="@{linkToHubService.addPath(action.path.toString)}" 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">
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 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -1,5 +1,4 @@
 @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._
@@ -11,6 +10,7 @@
   lang: LanguageCode = LanguageCode("en")
 )(action: Uri,
   csrf: Option[CsrfToken] = None,
+  linkToHubService: Uri,
   labels: List[Label],
   projectBaseUri: Uri,
   title: Option[String] = None,
@@ -25,8 +25,8 @@
   <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)
+        <h2><a href="@{linkToHubService.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2>
+        @showProjectMenu(baseUri, linkToHubService)(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")
@@ -54,7 +54,7 @@
           }
         </div>
         <div class="edit-labels-form">
-          <form action="@projectBaseUri.addSegment("labels")" class="pure-form pure-form-stacked" method="POST" accept-charset="UTF-8">
+          <form action="@{linkToHubService.addPath(projectBaseUri.path.toString).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>
@@ -106,7 +106,7 @@
                   </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">
+                    <form action="@{linkToHubService.addPath(projectBaseUri.path.toString).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">
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 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -1,5 +1,4 @@
 @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._
@@ -11,6 +10,7 @@
   lang: LanguageCode = LanguageCode("en")
 )(action: Uri,
   csrf: Option[CsrfToken] = None,
+  linkToHubService: Uri,
   milestone: Milestone,
   projectBaseUri: Uri,
   title: Option[String] = None,
@@ -25,8 +25,8 @@
   <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)
+        <h2><a href="@{linkToHubService.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2>
+        @showProjectMenu(baseUri, linkToHubService)(projectBaseUri.addSegment("milestones").some, projectBaseUri, user.some, project)
         <div class="project-summary-description">
           @Messages("project.milestones.edit.title")
         </div>
@@ -50,7 +50,7 @@
           }
         </div>
         <div class="edit-milestones-form">
-          <form action="@action" class="pure-form pure-form-aligned" method="POST" accept-charset="UTF-8">
+          <form action="@{linkToHubService.addPath(action.path.toString)}" 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">
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 09:48:54.192767006 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -1,6 +1,5 @@
 @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._
@@ -13,6 +12,7 @@
   lang: LanguageCode = LanguageCode("en")
 )(action: Uri,
   csrf: Option[CsrfToken] = None,
+  linkToHubService: Uri,
   milestones: List[Milestone],
   projectBaseUri: Uri,
   title: Option[String] = None,
@@ -27,8 +27,8 @@
   <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)
+        <h2><a href="@{linkToHubService.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2>
+        @showProjectMenu(baseUri, linkToHubService)(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")
@@ -56,7 +56,7 @@
           }
         </div>
         <div class="edit-milestones-form">
-          <form action="@projectBaseUri.addSegment("milestones")" class="pure-form pure-form-stacked" method="POST" accept-charset="UTF-8">
+          <form action="@{linkToHubService.addPath(projectBaseUri.path.toString).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>
@@ -108,7 +108,7 @@
                   </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">
+                    <form action="@{linkToHubService.addPath(projectBaseUri.path.toString).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">
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editTicket.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editTicket.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editTicket.scala.html	2025-01-16 09:48:54.196767011 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editTicket.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -1,5 +1,4 @@
 @import de.smederee.hub.Account
-@import de.smederee.hub.views.html.main
 @import de.smederee.tickets.TicketForm._
 @import de.smederee.tickets._
 @import de.smederee.tickets.forms._
@@ -10,6 +9,7 @@
   lang: LanguageCode = LanguageCode("en")
 )(action: Uri,
   csrf: Option[CsrfToken] = None,
+  linkToHubService: Uri,
   labels: List[Label] = Nil,
   milestones: List[Milestone] = Nil,
   projectBaseUri: Uri,
@@ -43,8 +43,8 @@
   <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)
+        <h2><a href="@{linkToHubService.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2>
+        @showProjectMenu(baseUri, linkToHubService)(action.some, projectBaseUri, user, project)
         <div class="project-summary-description">
           @Messages("project.tickets.edit.title", ticketNumber)
         </div>
@@ -67,7 +67,7 @@
           }
         </div>
         <div class="edit-tickets-form">
-          <form action="@projectBaseUri.addSegment("tickets").addSegment(ticketNumber.toString)" class="pure-form pure-form-stacked" method="POST" accept-charset="UTF-8">
+          <form action="@{linkToHubService.addPath(projectBaseUri.path.toString).addSegment("tickets").addSegment(ticketNumber.toString)}" class="pure-form pure-form-stacked" method="POST" accept-charset="UTF-8">
             <fieldset class="pure-group">
               <div class="pure-g">
                 <div class="pure-u-18-24 pure-u-md-18-24">
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/main.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/main.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/main.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/main.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -0,0 +1,33 @@
+@import de.smederee.hub.Account
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root),
+  lang: LanguageCode = LanguageCode("en"),
+  tags: MetaTags = MetaTags.empty
+)(customFooters: Html = Html(""),
+  customHeaders: Html = Html("")
+)(csrf: Option[CsrfToken],
+  title: Option[String],
+  user: Option[Account]
+)(content: Html)
+@defining(lang.toLocale) { implicit locale =>
+<!DOCTYPE html>
+<html lang="@lang">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  @meta(tags)
+  <title>@title</title>
+  <link rel="stylesheet" href="@{baseUri.addPath("assets/purecss/3.0.0/pure-min.css")}" />
+  <link rel="stylesheet" href="@{baseUri.addPath("assets/purecss/3.0.0/grids-responsive-min.css")}" />
+  <link rel="stylesheet" href="@{baseUri.addPath("assets/css/main.css")}" />
+  @customHeaders
+</head>
+<body>
+  <navbar class="header navbar" id="navbar-top">@navbar(baseUri, lang)(csrf, None, user)</navbar>
+  <main class="content-wrapper">
+    @content
+  </main>
+  @customFooters
+</body>
+</html>
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/meta.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/meta.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/meta.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/meta.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -0,0 +1,3 @@
+@(tags: MetaTags)
+@if(tags.description.nonEmpty){<meta name="description" content="@{tags.description}">}
+@if(tags.keywords.nonEmpty){<meta name="keywords" content="@{tags.keywords.mkString}">}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/navbar.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/navbar.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/navbar.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/navbar.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -0,0 +1,28 @@
+@import de.smederee.hub.Account
+
+@(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">
+  <a class="logo pure-menu-heading" href="@baseUri">@Messages("global.navbar.top.logo")</a>
+
+  <ul class="pure-menu-list">
+    <li class="pure-menu-item"><a class="pure-menu-link" href="@{baseUri.addPath("projects")}">@Messages("global.navbar.top.repositories.all")</a></li>
+    @if(user.nonEmpty) {
+      @for(account <- user) {
+        <li class="pure-menu-item"><a class="pure-menu-link" href="@{baseUri.addPath(s"~${account.name}")}">@Messages("global.navbar.top.repositories.yours")</a></li>
+      }
+      <li class="pure-menu-item"><a class="pure-menu-link" href="@{baseUri.addPath("repo/create")}">+ @Messages("global.navbar.top.repository.new")</a></li>
+      <li class="pure-menu-item"><a class="pure-menu-link" href="@{baseUri.addPath("user/settings")}">@Messages("global.navbar.top.settings")</a></li>
+      <li class="pure-menu-item">
+        <form action="@{baseUri.addPath("logout")}" method="POST" accept-charset="UTF-8" class="pure-form">
+          @csrfToken(csrf)
+          <button class="pure-button" type="submit">@Messages("global.logout")</button>
+        </form>
+      </li>
+    } else {
+      <li class="pure-menu-item"><a class="pure-menu-link" href="@{baseUri.addPath("login")}">@Messages("global.login")</a></li>
+      <li class="pure-menu-item"><a class="pure-menu-link" href="@{baseUri.addPath("signup")}">@Messages("global.signup")</a></li>
+    }
+  </ul>
+</nav>
+}
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	2025-01-16 09:48:54.196767011 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html	2025-01-16 09:48:54.200767016 +0000
@@ -1,7 +1,8 @@
-@import de.smederee.hub._
+@import de.smederee.hub.Account
 @import de.smederee.tickets.{ Project, ProjectOwnerId }
 
-@(baseUri: Uri
+@(baseUri: Uri,
+  linkToHubService: Uri
 )(activeUri: Option[Uri],
   projectBaseUri: Uri,
   user: Option[Account] = None,
@@ -9,8 +10,8 @@
 )(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.fromString(s"${uri.scheme.map(_.value).getOrElse("http")}://smeder.ee/${uri.path.toString}${uri.query.toString}").toOption}">@icon(baseUri)("eye") @Messages("project.menu.overview")</a></li>
+    @defining(linkToHubService.addPath(projectBaseUri.path.toString)) { 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("tickets")) { uri =>
     <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("crosshair") @Messages("project.menu.tickets")</a></li>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTicket.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTicket.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTicket.scala.html	2025-01-16 09:48:54.196767011 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTicket.scala.html	2025-01-16 09:48:54.204767020 +0000
@@ -1,5 +1,4 @@
 @import de.smederee.hub.Account
-@import de.smederee.hub.views.html.main
 @import de.smederee.tickets._
 @import de.smederee.tickets.views.html.format._
 
@@ -7,6 +6,7 @@
   lang: LanguageCode = LanguageCode("en")
 )(action: Uri,
   csrf: Option[CsrfToken] = None,
+  linkToHubService: Uri,
   labels: List[Label],
   milestones: List[Milestone],
   ticket: Ticket,
@@ -22,9 +22,9 @@
   <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">@ticket.number created by @formatTicketSubmitter(baseUri)(ticket) at @formatDateTime(ticket.createdAt)</div>
+        <h2><a href="@{linkToHubService.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2>
+        @showProjectMenu(baseUri, linkToHubService)(action.some, projectBaseUri, user, project)
+        <div class="project-summary-description">@ticket.number created by @formatTicketSubmitter(linkToHubService)(ticket) at @formatDateTime(ticket.createdAt)</div>
       </div>
     </div>
   </div>
@@ -48,7 +48,7 @@
         <div class="pure-g ticket-sidebar">
           <div class="pure-u-1-5">Status</div><div class="pure-u-4-5">@formatTicketStatus(ticket)</div>
           <div class="pure-u-1-5">Assigned</div><div class="pure-u-4-5"></div>
-          <div class="pure-u-1-5">Reported</div><div class="pure-u-4-5">@formatTicketSubmitter(baseUri)(ticket) at @formatDateTime(ticket.createdAt)</div>
+          <div class="pure-u-1-5">Reported</div><div class="pure-u-4-5">@formatTicketSubmitter(linkToHubService)(ticket) at @formatDateTime(ticket.createdAt)</div>
           @if(milestones.nonEmpty) {
             <div class="pure-u-1-5">Milestones</div>
             <div class="pure-u-4-5">
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html	2025-01-16 09:48:54.196767011 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html	2025-01-16 09:48:54.204767020 +0000
@@ -1,5 +1,4 @@
 @import de.smederee.hub.Account
-@import de.smederee.hub.views.html.main
 @import de.smederee.tickets._
 @import de.smederee.tickets.views.html.format.formatDateTime
 
@@ -7,6 +6,7 @@
   lang: LanguageCode = LanguageCode("en")
 )(action: Uri,
   csrf: Option[CsrfToken] = None,
+  linkToHubService: Uri,
   tickets: List[Ticket],
   projectBaseUri: Uri,
   title: Option[String] = None,
@@ -19,8 +19,8 @@
   <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)
+        <h2><a href="@{linkToHubService.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2>
+        @showProjectMenu(baseUri, linkToHubService)(action.some, projectBaseUri, user, project)
         <div class="project-summary-description">
           @Messages("project.tickets.view.title")
         </div>
@@ -66,7 +66,7 @@
                 <div class="pure-u-8-24"></div>
                 <div class="pure-u-3-24">@ticket.status</div>
                 <div class="pure-u-3-24">@ticket.resolution</div>
-                <div class="pure-u-3-24">@for(submitter <- ticket.submitter){<a href="@{baseUri.addSegment(s"~${submitter.name}")}">@submitter.name</a>}</div>
+                <div class="pure-u-3-24">@for(submitter <- ticket.submitter){<a href="@{linkToHubService.addSegment(s"~${submitter.name}")}">@submitter.name</a>}</div>
                 <div class="pure-u-6-24">@formatDateTime(ticket.updatedAt)</div>
               </div>
             }
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-01-16 09:48:54.196767011 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-01-16 09:48:54.204767020 +0000
@@ -189,12 +189,13 @@
   val genValidVcsType = Gen.oneOf(VcsType.values.toList)
 
   val genValidVcsRepository: Gen[VcsRepository] = for {
-    name        <- genValidVcsRepositoryName
-    owner       <- genValidVcsRepositoryOwner
-    isPrivate   <- Gen.oneOf(List(false, true))
-    description <- Gen.alphaNumStr.map(VcsRepositoryDescription.from)
-    vcsType     <- genValidVcsType
-  } yield VcsRepository(name, owner, isPrivate, description, vcsType, None)
+    name           <- genValidVcsRepositoryName
+    owner          <- genValidVcsRepositoryOwner
+    isPrivate      <- Gen.oneOf(List(false, true))
+    description    <- Gen.alphaNumStr.map(VcsRepositoryDescription.from)
+    ticketsEnabled <- Gen.oneOf(List(false, true))
+    vcsType        <- genValidVcsType
+  } yield VcsRepository(name, owner, isPrivate, description, ticketsEnabled, vcsType, None)
 
   val genValidVcsRepositories: Gen[List[VcsRepository]] = Gen.nonEmptyListOf(genValidVcsRepository)
 
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	2025-01-16 09:48:54.196767011 +0000
+++ new-smederee/modules/tickets/src/main/resources/reference.conf	2025-01-16 09:48:54.204767020 +0000
@@ -5,103 +5,110 @@
 tickets {
   # Authentication / login settings
   authentication {
-	enabled = true
+    enabled = true
 
-	# The name used for the authentication cookie.
-	cookie-name = "sloetel"
+    # 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
-	}
+    # 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"
+    # 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}
+    # 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"
+    # 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"
+  }
+
+  # Configuration regarding the integration with the hub service.
+  hub-integration {
+    # The base URI used to build links to the hub service.
+    base-uri = "http://localhost:8080"
+    base-uri = ${?SMEDEREE_HUB_BASE_URI}
   }
 
   # 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
+    # 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/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	2025-01-16 09:48:54.196767011 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala	2025-01-16 09:48:54.204767020 +0000
@@ -45,6 +45,34 @@
     ConfigReader.forProduct3("host", "port", "scheme")(CsrfProtectionConfiguration.apply)
 }
 
+/** Configuration regarding the integration with the hub service.
+  *
+  * @param baseUri
+  *   The base URI used to build links to the hub service.
+  */
+final case class HubIntegrationConfiguration(baseUri: Uri)
+
+object HubIntegrationConfiguration {
+  given ConfigReader[Uri] = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption)
+  given ConfigReader[HubIntegrationConfiguration] =
+    ConfigReader.forProduct1("base-uri")(HubIntegrationConfiguration.apply)
+}
+
+/** Generic service configuration determining how the service will be run.
+  *
+  * @param host
+  *   The hostname on which the service shall listen for requests.
+  * @param port
+  *   The TCP port number on which the service shall listen for requests.
+  */
+final case class ServiceConfiguration(host: Host, port: Port)
+
+object ServiceConfiguration {
+  given ConfigReader[Host]                 = ConfigReader.fromStringOpt[Host](Host.fromString)
+  given ConfigReader[Port]                 = ConfigReader.fromStringOpt[Port](Port.fromString)
+  given ConfigReader[ServiceConfiguration] = ConfigReader.forProduct2("host", "port")(ServiceConfiguration.apply)
+}
+
 /** Wrapper class for the confiuration of the Smederee tickets module.
   *
   * @param csrfProtection
@@ -54,11 +82,17 @@
   * @param externalUrl
   *   Configuration regarding support for generating "external urls" which is usually needed if the service runs behind
   *   a reverse proxy.
+  * @param hub
+  *   Configuration regarding the integration with the hub service.
+  * @param service
+  *   Generic service configuration determining how the service will be run.
   */
 final case class SmedereeTicketsConfiguration(
     csrfProtection: CsrfProtectionConfiguration,
     database: DatabaseConfig,
-    externalUrl: ExternalUrlConfiguration
+    externalUrl: ExternalUrlConfiguration,
+    hub: HubIntegrationConfiguration,
+    service: ServiceConfiguration
 )
 
 object SmedereeTicketsConfiguration {
@@ -73,5 +107,7 @@
     ConfigReader.forProduct4("host", "path", "port", "scheme")(ExternalUrlConfiguration.apply)
 
   given ConfigReader[SmedereeTicketsConfiguration] =
-    ConfigReader.forProduct3("csrf-protection", "database", "external-url")(SmedereeTicketsConfiguration.apply)
+    ConfigReader.forProduct5("csrf-protection", "database", "external-url", "hub-integration", "service")(
+      SmedereeTicketsConfiguration.apply
+    )
 }
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala	2025-01-16 09:48:54.204767020 +0000
@@ -0,0 +1,71 @@
+/*
+ * 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.syntax.all._
+import com.typesafe.config._
+import org.http4s.Uri
+import org.http4s.implicits._
+import pureconfig._
+
+import munit._
+
+final class SmedereeTicketsConfigurationTest extends FunSuite {
+  val rawDefaultConfig = new Fixture[Config]("defaultConfig") {
+    def apply() = ConfigFactory.load(getClass.getClassLoader)
+  }
+
+  override def munitFixtures = List(rawDefaultConfig)
+
+  test("must load from the default configuration successfully") {
+    ConfigSource
+      .fromConfig(rawDefaultConfig())
+      .at(s"${SmedereeTicketsConfiguration.location.toString}")
+      .load[SmedereeTicketsConfiguration] match {
+      case Left(errors) => fail(errors.toList.mkString(", "))
+      case Right(_)     => assert(true)
+    }
+  }
+
+  test("default values for external linking must be setup for local development") {
+    ConfigSource
+      .fromConfig(rawDefaultConfig())
+      .at(s"${SmedereeTicketsConfiguration.location.toString}")
+      .load[SmedereeTicketsConfiguration] match {
+      case Left(errors) => fail(errors.toList.mkString(", "))
+      case Right(cfg) =>
+        val externalCfg = cfg.externalUrl
+        assertEquals(externalCfg.host, cfg.service.host)
+        assertEquals(externalCfg.port, Option(cfg.service.port))
+        assert(externalCfg.path.isEmpty)
+        assertEquals(externalCfg.scheme, Uri.Scheme.http)
+    }
+  }
+
+  test("default values for hub service integration must be setup for local development") {
+    ConfigSource
+      .fromConfig(rawDefaultConfig())
+      .at(s"${SmedereeTicketsConfiguration.location.toString}")
+      .load[SmedereeTicketsConfiguration] match {
+      case Left(errors) => fail(errors.toList.mkString(", "))
+      case Right(cfg) =>
+        val expectedUri = uri"http://localhost:8080"
+        assertEquals(cfg.hub.baseUri, expectedUri)
+    }
+  }
+}