~jan0sch/smederee

Showing details for patch 7a198c7f10e5c6dc3a980897adec7b0ffbf6403e.
2022-08-14 (Sun), 8:11 AM - Jens Grassel - 7a198c7f10e5c6dc3a980897adec7b0ffbf6403e

VCS: Database and Repository

- more work on db table
- data structures
- VcsMetadataRepository and tests
- change main CSS (colourize alerts)
- add routes for showing all repositories (also for guests)
Summary of changes
4 files added
  • modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala
  • modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala
  • modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala
  • modules/hub/src/main/twirl/de/smederee/hub/views/showAllRepositories.scala.html
14 files modified with 232 lines added and 77 lines removed
  • modules/hub/src/it/scala/de/smederee/hub/Generators.scala with 19 added and 0 removed lines
  • modules/hub/src/main/resources/assets/css/main.css with 11 added and 11 removed lines
  • modules/hub/src/main/resources/db/migration/V2__repository_tables.sql with 1 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/Account.scala with 10 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 6 added and 5 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/NewVcsRepositoryForm.scala with 23 added and 15 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala with 29 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 103 added and 35 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/createRepository.scala.html with 0 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/login.scala.html with 0 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html with 1 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html with 10 added and 8 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/signup.scala.html with 0 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/Generators.scala with 19 added and 0 removed lines
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-02-02 16:56:14.782979214 +0000
@@ -0,0 +1,390 @@
+/*
+ * Copyright (c) 2022 Wegtam GmbH
+ *
+ * Business Source License 1.1 - See file LICENSE for details!
+ *
+ * Change Date:    2025-06-21
+ * Change License: GNU AFFERO GENERAL PUBLIC LICENSE Version 3
+ */
+
+package de.smederee.hub
+
+import cats.effect._
+import cats.syntax.all._
+import de.smederee.hub.Generators._
+import de.smederee.hub.VcsMetadataRepositoriesOrdering._
+import de.smederee.hub.config.SmedereeHubConfig
+import doobie._
+import org.flywaydb.core.Flyway
+
+import munit._
+
+final class DoobieVcsMetadataRepositoryTest extends BaseSpec {
+
+  /** Find the repository ID for the given owner and repository name.
+    *
+    * @param owner
+    *   The unique ID of the user account that owns the repository.
+    * @param name
+    *   The repository name which must be unique in regard to the owner.
+    * @return
+    *   An option to the internal database ID.
+    */
+  @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+  protected def findVcsRepositoryId(owner: UserId, name: VcsRepositoryName): IO[Option[Long]] =
+    connectToDb(configuration).use { con =>
+      for {
+        statement <- IO.delay(
+          con.prepareStatement(
+            """SELECT id FROM "repositories" WHERE owner = ? AND name = ? LIMIT 1"""
+          )
+        )
+        _      <- IO.delay(statement.setObject(1, owner))
+        _      <- IO.delay(statement.setString(2, name.toString))
+        result <- IO.delay(statement.executeQuery)
+        account <- IO.delay {
+          if (result.next()) {
+            Option(result.getLong("id"))
+          } else {
+            None
+          }
+        }
+        _ <- IO(statement.close())
+      } yield account
+    }
+
+  override def beforeEach(context: BeforeEach): Unit = {
+    val dbConfig       = configuration.database
+    val flyway: Flyway = Flyway.configure().dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
+    val _              = flyway.migrate()
+    val _              = flyway.clean()
+    val _              = flyway.migrate()
+  }
+
+  override def afterEach(context: AfterEach): Unit = {
+    val dbConfig       = configuration.database
+    val flyway: Flyway = Flyway.configure().dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
+    val _              = flyway.migrate()
+    val _              = flyway.clean()
+  }
+
+  test("createVcsRepository must create a repository entry") {
+    (genValidAccount.sample, genValidVcsRepository.sample) match {
+      case (Some(account), Some(repository)) =>
+        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
+        val dbConfig      = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _       <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          written <- repo.createVcsRepository(vcsRepository)
+        } yield written
+        test.map { written =>
+          assert(written === 1, "Creating a vcs repository must modify one database row!")
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("createVcsRepository must fail if the user does not exist") {
+    genValidVcsRepository.sample match {
+      case Some(repository) =>
+        val dbConfig = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          written <- repo.createVcsRepository(repository)
+        } yield written
+        test.attempt.map(result => assert(result.isLeft))
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("createVcsRepository must fail if a repository with the same name exists") {
+    (genValidAccount.sample, genValidVcsRepository.sample) match {
+      case (Some(account), Some(repository)) =>
+        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
+        val dbConfig      = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _       <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          _       <- repo.createVcsRepository(vcsRepository)
+          written <- repo.createVcsRepository(vcsRepository)
+        } yield written
+        test.attempt.map(result => assert(result.isLeft))
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("findVcsRepository must return an existing repository") {
+    (genValidAccount.sample, genValidVcsRepository.sample) match {
+      case (Some(account), Some(repository)) =>
+        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
+        val dbConfig      = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _         <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          written   <- repo.createVcsRepository(vcsRepository)
+          foundRepo <- repo.findVcsRepository(vcsRepository.owner, vcsRepository.name)
+        } yield (written, foundRepo)
+        test.map { result =>
+          val (written, foundRepo) = result
+          assert(written === 1, "Test repository data was not written to database!")
+          foundRepo match {
+            case None       => fail("Repository was not found!")
+            case Some(repo) => assert(repo === vcsRepository)
+          }
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("findVcsRepositoryId must return the id of an existing repository") {
+    (genValidAccount.sample, genValidVcsRepository.sample) match {
+      case (Some(account), Some(repository)) =>
+        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
+        val dbConfig      = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _           <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          writtenRows <- repo.createVcsRepository(vcsRepository)
+          writtenId   <- findVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name)
+          foundId     <- repo.findVcsRepositoryId(vcsRepository.owner, vcsRepository.name)
+        } yield (writtenRows, writtenId, foundId)
+        test.map { result =>
+          val (written, writtenId, foundId) = result
+          assert(written === 1, "Test repository data was not written to database!")
+          assert(writtenId.nonEmpty)
+          assert(foundId.nonEmpty)
+          assert(writtenId === foundId)
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("findVcsRepositoryOwner must return the correct account") {
+    genValidAccounts.sample match {
+      case Some(accounts) =>
+        val expectedOwner = accounts(scala.util.Random.nextInt(accounts.size)).toVcsRepositoryOwner
+        val dbConfig      = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          written <- accounts.traverse(account =>
+            createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          )
+          foundOwner <- repo.findVcsRepositoryOwner(expectedOwner.name)
+        } yield (written, foundOwner)
+        test.map { result =>
+          val (written, foundOwner) = result
+          assert(
+            written.filter(_ === 1).size === written.size,
+            "Not all test repository data was not written to database!"
+          )
+          foundOwner match {
+            case None        => fail("Vcs repository owner not found!")
+            case Some(owner) => assertEquals(owner, expectedOwner)
+          }
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("listAllRepositories must return only public repositories for guest users") {
+    (genValidAccount.sample, genValidVcsRepositories.sample) match {
+      case (Some(account), Some(repositories)) =>
+        val vcsRepositories = repositories
+        val accounts = vcsRepositories.map(repo =>
+          Account(
+            repo.owner.uid,
+            repo.owner.name,
+            Email(s"${repo.owner.name}@example.com"),
+            verifiedEmail = true
+          )
+        )
+        val expectedRepoList = vcsRepositories.filter(_.isPrivate === false)
+        val dbConfig         = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          _ <- accounts.traverse(account =>
+            createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          )
+          written    <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository))
+          foundRepos <- repo.listAllRepositories(None)(NameAscending).compile.toList
+        } yield (written, foundRepos)
+        test.map { result =>
+          val (written, foundRepos) = result
+          assert(
+            written.filter(_ === 1).size === written.size,
+            "Not all test repository data was not written to database!"
+          )
+          assertEquals(foundRepos.size, expectedRepoList.size)
+        }
+
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("listAllRepositories must return only public repositories of others for any user") {
+    (genValidAccount.sample, genValidVcsRepositories.sample) match {
+      case (Some(account), Some(repositories)) =>
+        val vcsRepositories = repositories
+        val accounts = vcsRepositories.map(repo =>
+          Account(
+            repo.owner.uid,
+            repo.owner.name,
+            Email(s"${repo.owner.name}@example.com"),
+            verifiedEmail = true
+          )
+        )
+        val expectedRepoList = vcsRepositories.filter(_.isPrivate === false)
+        val dbConfig         = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          _ <- accounts.traverse(account =>
+            createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          )
+          written    <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository))
+          foundRepos <- repo.listAllRepositories(account.some)(NameDescending).compile.toList
+        } yield (written, foundRepos)
+        test.map { result =>
+          val (written, foundRepos) = result
+          assert(
+            written.filter(_ === 1).size === written.size,
+            "Not all test repository data was not written to database!"
+          )
+          assertEquals(foundRepos.size, expectedRepoList.size)
+        }
+
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("listAllRepositories must include all private repositories of the user") {
+    (genValidAccount.sample, genValidVcsRepositories.sample) match {
+      case (Some(account), Some(repositories)) =>
+        val privateRepos =
+          repositories.filter(_.isPrivate === true).map(_.copy(owner = account.toVcsRepositoryOwner))
+        val publicRepos     = repositories.filter(_.isPrivate === false)
+        val vcsRepositories = privateRepos ::: publicRepos
+        val accounts = publicRepos.map(repo =>
+          Account(
+            repo.owner.uid,
+            repo.owner.name,
+            Email(s"${repo.owner.name}@example.com"),
+            verifiedEmail = true
+          )
+        )
+        val expectedRepoList = vcsRepositories
+        val dbConfig         = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          _ <- accounts.traverse(account =>
+            createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          )
+          written    <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository))
+          foundRepos <- repo.listAllRepositories(account.some)(NameDescending).compile.toList
+        } yield (written, foundRepos)
+        test.map { result =>
+          val (written, foundRepos) = result
+          assert(
+            written.filter(_ === 1).size === written.size,
+            "Not all test repository data was not written to database!"
+          )
+          assertEquals(foundRepos.size, expectedRepoList.size)
+        }
+
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("listRepositories must return only public repositories for guest users") {
+    (genValidAccount.sample, genValidVcsRepositories.sample) match {
+      case (Some(account), Some(repositories)) =>
+        val vcsRepositories  = repositories.map(_.copy(owner = account.toVcsRepositoryOwner))
+        val expectedRepoList = vcsRepositories.filter(_.isPrivate === false).sortBy(_.name)
+        val dbConfig         = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _          <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          written    <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository))
+          foundRepos <- repo.listRepositories(None)(account.toVcsRepositoryOwner).compile.toList
+        } yield (written, foundRepos)
+        test.map { result =>
+          val (written, foundRepos) = result
+          assert(
+            written.filter(_ === 1).size === written.size,
+            "Not all test repository data was not written to database!"
+          )
+          assertEquals(foundRepos.size, expectedRepoList.size)
+          // We sort again because database sorting might differ slightly from code sorting.
+          assertEquals(foundRepos.sortBy(_.name), expectedRepoList)
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("listRepositories must return all repositories for the owner") {
+    (genValidAccount.sample, genValidVcsRepositories.sample) match {
+      case (Some(account), Some(repositories)) =>
+        val vcsRepositories  = repositories.map(_.copy(owner = account.toVcsRepositoryOwner))
+        val expectedRepoList = vcsRepositories.sortBy(_.name)
+        val dbConfig         = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _          <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          written    <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository))
+          foundRepos <- repo.listRepositories(account.some)(account.toVcsRepositoryOwner).compile.toList
+        } yield (written, foundRepos)
+        test.map { result =>
+          val (written, foundRepos) = result
+          assert(
+            written.filter(_ === 1).size === written.size,
+            "Not all test repository data was not written to database!"
+          )
+          assertEquals(foundRepos.size, expectedRepoList.size)
+          // We sort again because database sorting might differ slightly from code sorting.
+          assertEquals(foundRepos.sortBy(_.name), expectedRepoList)
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("listRepositories must return only public repositories for any user") {
+    (genValidAccount.sample, genValidVcsRepositories.sample, genValidAccount.sample) match {
+      case (Some(account), Some(repositories), Some(otherAccount)) =>
+        val vcsRepositories  = repositories.map(_.copy(owner = account.toVcsRepositoryOwner))
+        val expectedRepoList = vcsRepositories.filter(_.isPrivate === false).sortBy(_.name)
+        val dbConfig         = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _          <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          written    <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository))
+          foundRepos <- repo.listRepositories(otherAccount.some)(account.toVcsRepositoryOwner).compile.toList
+        } yield (written, foundRepos)
+        test.map { result =>
+          val (written, foundRepos) = result
+          assert(
+            written.filter(_ === 1).size === written.size,
+            "Not all test repository data was not written to database!"
+          )
+          assertEquals(foundRepos.size, expectedRepoList.size)
+          // We sort again because database sorting might differ slightly from code sorting.
+          assertEquals(foundRepos.sortBy(_.name), expectedRepoList)
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+}
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-02-02 16:56:14.778979205 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala	2025-02-02 16:56:14.782979214 +0000
@@ -13,6 +13,7 @@
 import java.time._
 import java.util.{ Locale, UUID }
 
+import cats.syntax.all._
 import de.smederee.security.{ PrivateKey, SignAndValidate }
 
 import org.scalacheck._
@@ -95,6 +96,10 @@
 
   given Arbitrary[Account] = Arbitrary(genValidAccount)
 
+  val genValidAccounts: Gen[List[Account]] = Gen
+    .nonEmptyListOf(genValidAccount)
+    .suchThat(accounts => accounts.size === accounts.map(_.name).distinct.size) // Ensure unique names.
+
   val genValidSession: Gen[Session] =
     for {
       id  <- genSessionId
@@ -152,4 +157,18 @@
     )
     .map(cs => VcsRepositoryName(cs.take(64).mkString))
 
+  val genValidVcsRepositoryOwner = for {
+    uid  <- genUserId
+    name <- genValidUsername
+  } yield VcsRepositoryOwner(uid, name)
+
+  val genValidVcsRepository: Gen[VcsRepository] = for {
+    name        <- genValidVcsRepositoryName
+    owner       <- genValidVcsRepositoryOwner
+    isPrivate   <- Gen.oneOf(List(false, true))
+    description <- Gen.alphaNumStr.map(VcsRepositoryDescription.from)
+  } yield VcsRepository(name, owner, isPrivate, description, None)
+
+  val genValidVcsRepositories: Gen[List[VcsRepository]] = Gen.nonEmptyListOf(genValidVcsRepository)
+
 }
diff -rN -u old-smederee/modules/hub/src/main/resources/assets/css/main.css new-smederee/modules/hub/src/main/resources/assets/css/main.css
--- old-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-02-02 16:56:14.778979205 +0000
+++ new-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-02-02 16:56:14.782979214 +0000
@@ -38,26 +38,26 @@
 .alert {
   border: 1px solid;
   border-radius: 0.375rem;
+  color: white;
   padding: 5px;
   position: relative;
+  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
 }
 
-.alert-danger {
-  color: #842029;
-  background-color: #f8d7da;
-  border-color: #f5c2c7;
+.alert-error {
+  background: rgb(202, 60, 60);
 }
 
-.alert-primary {
-  color: #ffffff;
-  background-color: #1f8dd6;
-  border-color: #1f8dd6;
+.alert-secondary {
+  background: rgb(66, 184, 221);
+}
+
+.alert-success {
+  background: rgb(28, 184, 65);
 }
 
 .alert-warning {
-  color: #664d03;
-  background-color: #fff3cd;
-  border-color: #ffecb5;
+  background: rgb(223, 117, 20);
 }
 
 .home-menu {
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql	2025-02-02 16:56:14.778979205 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql	2025-02-02 16:56:14.782979214 +0000
@@ -3,7 +3,7 @@
   "id"          BIGINT                   GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
   "name"        CHARACTER VARYING(64)    NOT NULL,
   "owner"       UUID                     NOT NULL,
-  "is_private"  BOOLEAN                  DEFAULT FALSE,
+  "is_private"  BOOLEAN                  NOT NULL DEFAULT FALSE,
   "description" CHARACTER VARYING(254),
   "website"     TEXT,
   "created_at"  TIMESTAMP WITH TIME ZONE NOT NULL,
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala	2025-02-02 16:56:14.778979205 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala	2025-02-02 16:56:14.782979214 +0000
@@ -361,3 +361,13 @@
       a.uid === b.uid && a.name === b.name && a.email === b.email
     }
 }
+
+extension (account: Account) {
+
+  /** Create vcs repository owner metadata from the account.
+    *
+    * @return
+    *   Descriptive information about the owner of a vcs repository based on the account.
+    */
+  def toVcsRepositoryOwner: VcsRepositoryOwner = VcsRepositoryOwner(account.uid, account.name)
+}
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-02-02 16:56:14.782979214 +0000
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2022 Wegtam GmbH
+ *
+ * Business Source License 1.1 - See file LICENSE for details!
+ *
+ * Change Date:    2025-06-21
+ * Change License: GNU AFFERO GENERAL PUBLIC LICENSE Version 3
+ */
+
+package de.smederee.hub
+
+import java.util.UUID
+
+import cats.effect._
+import cats.syntax.all._
+import de.smederee.hub.VcsMetadataRepositoriesOrdering._
+import doobie._
+import doobie.Fragments._
+import doobie.implicits._
+import doobie.postgres.implicits._
+import fs2.Stream
+import org.http4s.Uri
+
+final class DoobieVcsMetadataRepository[F[_]: Sync](tx: Transactor[F]) extends VcsMetadataRepository[F] {
+
+  given Meta[VcsRepositoryName]        = Meta[String].timap(VcsRepositoryName.apply)(_.toString)
+  given Meta[VcsRepositoryDescription] = Meta[String].timap(VcsRepositoryDescription.apply)(_.toString)
+  given Meta[Uri]                      = Meta[String].timap(Uri.unsafeFromString)(_.toString)
+  given Meta[UserId]                   = Meta[UUID].timap(UserId.apply)(_.toUUID)
+  given Meta[Username]                 = Meta[String].timap(Username.apply)(_.toString)
+
+  private val selectRepositoryColumns =
+    fr"""SELECT "repos".name AS name, "accounts".uid AS owner_id, "accounts".name AS owner_name, "repos".is_private AS is_private, "repos".description AS description, "repos".website AS website FROM "repositories" AS "repos" JOIN "accounts" ON "repos".owner = "accounts".uid"""
+
+  override def createVcsRepository(repository: VcsRepository): F[Int] =
+    sql"""INSERT INTO "repositories" (name, owner, is_private, description, website, created_at, updated_at) VALUES (${repository.name}, ${repository.owner.uid}, ${repository.isPrivate}, ${repository.description}, ${repository.website}, NOW(), NOW())""".update.run
+      .transact(tx)
+
+  override def findVcsRepository(
+      owner: VcsRepositoryOwner,
+      name: VcsRepositoryName
+  ): F[Option[VcsRepository]] = {
+    val nameFilter  = fr""""repos".name = $name"""
+    val ownerFilter = fr""""repos".owner = ${owner.uid}"""
+    val query       = selectRepositoryColumns ++ whereAnd(ownerFilter, nameFilter) ++ fr"""LIMIT 1"""
+    query.query[VcsRepository].option.transact(tx)
+  }
+
+  override def findVcsRepositoryId(owner: VcsRepositoryOwner, name: VcsRepositoryName): F[Option[Long]] = {
+    val nameFilter  = fr"""name = $name"""
+    val ownerFilter = fr"""owner = ${owner.uid}"""
+    val query = fr"""SELECT id FROM "repositories"""" ++ whereAnd(ownerFilter, nameFilter) ++ fr"""LIMIT 1"""
+    query.query[Long].option.transact(tx)
+  }
+
+  override def findVcsRepositoryOwner(name: Username): F[Option[VcsRepositoryOwner]] =
+    sql"""SELECT uid, name FROM "accounts" WHERE name = $name LIMIT 1"""
+      .query[VcsRepositoryOwner]
+      .option
+      .transact(tx)
+
+  override def listAllRepositories(
+      requester: Option[Account]
+  )(ordering: VcsMetadataRepositoriesOrdering): Stream[F, VcsRepository] = {
+    val orderClause = ordering match {
+      case NameAscending  => fr"""ORDER BY name ASC"""
+      case NameDescending => fr"""ORDER BY name DESC"""
+    }
+    // We use a SQL UNION here which first queries all others repos and then (potentially) the repos of the requester.
+    val query = requester.fold(selectRepositoryColumns ++ fr"""WHERE is_private = FALSE""")(account =>
+      selectRepositoryColumns ++ fr"""WHERE is_private = FALSE AND owner != ${account.uid}""" ++ fr"""UNION""" ++ selectRepositoryColumns ++ fr"""WHERE owner = ${account.uid}"""
+    ) ++ orderClause
+    query.query[VcsRepository].stream.transact(tx)
+  }
+
+  override def listRepositories(
+      requester: Option[Account]
+  )(owner: VcsRepositoryOwner): Stream[F, VcsRepository] = {
+    val ownerFilter = fr""""repos".owner = ${owner.uid}"""
+    val whereClause = requester match {
+      case None => whereAnd(ownerFilter, fr""""repos".is_private = FALSE""") // Guest only see public repos.
+      case Some(account) =>
+        if (account.uid === owner.uid)
+          whereAnd(ownerFilter) // The user asks for their own repositories.
+        else
+          whereAnd(ownerFilter, fr""""repos".is_private = FALSE""") // TODO More logic later (groups, perms).
+    }
+    val query = selectRepositoryColumns ++ whereClause ++ fr"""ORDER BY name ASC"""
+    query.query[VcsRepository].stream.transact(tx)
+  }
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-02-02 16:56:14.778979205 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-02-02 16:56:14.782979214 +0000
@@ -101,16 +101,17 @@
         authenticationRepo,
         signAndValidate
       )
-      signUpRepo    = new DoobieSignupRepository[IO](transactor)
-      signUpRoutes  = new SignupRoutes[IO](configuration.service.signup, signUpRepo)
-      landingPages  = new LandingPageRoutes[IO]()
-      vcsRepoRoutes = new VcsRepositoryRoutes[IO](configuration.service.darcs, darcsWrapper)
+      signUpRepo      = new DoobieSignupRepository[IO](transactor)
+      signUpRoutes    = new SignupRoutes[IO](configuration.service.signup, signUpRepo)
+      landingPages    = new LandingPageRoutes[IO]()
+      vcsMetadataRepo = new DoobieVcsMetadataRepository[IO](transactor)
+      vcsRepoRoutes = new VcsRepositoryRoutes[IO](configuration.service.darcs, darcsWrapper, vcsMetadataRepo)
       protectedRoutesWithFallThrough = authenticationWithFallThrough(
         authenticationRoutes.protectedRoutes <+> signUpRoutes.protectedRoutes <+> vcsRepoRoutes.protectedRoutes <+> landingPages.protectedRoutes
       )
       globalRoutes = Router(
         Constants.assetsPath.path.toAbsolute.toString -> assetsRoutes,
-        "/" -> (protectedRoutesWithFallThrough <+> authenticationRoutes.routes <+> signUpRoutes.routes <+> landingPages.routes)
+        "/" -> (protectedRoutesWithFallThrough <+> authenticationRoutes.routes <+> signUpRoutes.routes <+> vcsRepoRoutes.routes <+> landingPages.routes)
       ).orNotFound
       resource = EmberServerBuilder
         .default[IO]
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-02-02 16:56:14.778979205 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/NewVcsRepositoryForm.scala	2025-02-02 16:56:14.782979214 +0000
@@ -53,25 +53,33 @@
       data.get(fieldIsPrivate).fold(false.validNec)(s => s.matches("true").validNec)
     val description = data
       .get(fieldDescription)
-      .fold(Option.empty[VcsRepositoryDescription].validNec)(s =>
-        VcsRepositoryDescription
-          .from(s)
-          .fold(FormFieldError("Invalid repository description!").invalidNec)(descr => Option(descr).validNec)
-      )
+      .fold(Option.empty[VcsRepositoryDescription].validNec) { s =>
+        if (s.trim.isEmpty)
+          Option.empty[VcsRepositoryDescription].validNec // Sometimes "empty" strings are sent.
+        else
+          VcsRepositoryDescription
+            .from(s)
+            .fold(FormFieldError("Invalid repository description!").invalidNec)(descr =>
+              Option(descr).validNec
+            )
+      }
       .leftMap(es => NonEmptyChain.of(Map(fieldDescription -> es.toList)))
     val website = data
       .get(fieldWebsite)
-      .fold(Option.empty[Uri].validNec)(s =>
-        Uri
-          .fromString(s)
-          .toOption
-          .fold(FormFieldError("Invalid website URI!").invalidNec) { uri =>
-            uri.scheme match {
-              case Some(Uri.Scheme.http) | Some(Uri.Scheme.https) => Option(uri).validNec
-              case _ => FormFieldError("Invalid website URI!").invalidNec
+      .fold(Option.empty[Uri].validNec) { s =>
+        if (s.trim.isEmpty)
+          Option.empty[Uri].validNec // Sometimes "empty" strings are sent.
+        else
+          Uri
+            .fromString(s)
+            .toOption
+            .fold(FormFieldError("Invalid website URI!").invalidNec) { uri =>
+              uri.scheme match {
+                case Some(Uri.Scheme.http) | Some(Uri.Scheme.https) => Option(uri).validNec
+                case _ => FormFieldError("Invalid website URI!").invalidNec
+              }
             }
-          }
-      )
+      }
       .leftMap(es => NonEmptyChain.of(Map(fieldWebsite -> es.toList)))
     (name, privateFlag, description, website).mapN {
       case (validName, isPrivate, validDescription, validWebsite) =>
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala	2025-02-02 16:56:14.782979214 +0000
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2022 Wegtam GmbH
+ *
+ * Business Source License 1.1 - See file LICENSE for details!
+ *
+ * Change Date:    2025-06-21
+ * Change License: GNU AFFERO GENERAL PUBLIC LICENSE Version 3
+ */
+
+package de.smederee.hub
+
+import fs2.Stream
+
+/** A base class for a database repository that should handle all functionality regarding vcs repositories and
+  * their metadata in the database.
+  *
+  * @tparm
+  *   F A higher kinded type which wraps the actual return values.
+  */
+abstract class VcsMetadataRepository[F[_]] {
+
+  /** Create a database entry for the given vcs repository.
+    *
+    * @param repository
+    *   The vcs repository metadata that shall be written to the database.
+    * @return
+    *   The number of affected database rows.
+    */
+  def createVcsRepository(repository: VcsRepository): F[Int]
+
+  /** Search for the vcs repository entry with the given owner and name.
+    *
+    * @param owner
+    *   Data about the owner of the repository containing information needed to query the database.
+    * @param name
+    *   The repository name which must be unique in regard to the owner.
+    * @return
+    *   An option to the successfully found vcs repository entry.
+    */
+  def findVcsRepository(owner: VcsRepositoryOwner, name: VcsRepositoryName): F[Option[VcsRepository]]
+
+  /** Search for the internal database specific (auto generated) ID of the given owner / repository
+    * combination which serves as a primary key for the database table.
+    *
+    * @param owner
+    *   Data about the owner of the repository containing information needed to query the database.
+    * @param name
+    *   The repository name which must be unique in regard to the owner.
+    * @return
+    *   An option to the internal database ID.
+    */
+  def findVcsRepositoryId(owner: VcsRepositoryOwner, name: VcsRepositoryName): F[Option[Long]]
+
+  /** Search for a repository owner of whom we only know the name.
+    *
+    * @param name
+    *   The name of the repository owner which is the username of the actual owners account.
+    * @return
+    *   An option to successfully found repository owner.
+    */
+  def findVcsRepositoryOwner(name: Username): F[Option[VcsRepositoryOwner]]
+
+  /** Return a list of all repositories from all users.
+    *
+    * @param requester
+    *   An optional account (None if guest i.e. not logged in user) for whom the list shall be created. This
+    *   will affect which repositories will be returned in regard to access rights.
+    * @param ordering
+    *   The desired ordering of the list.
+    * @return
+    *   A stream of vcs repository entries in the requested order.
+    */
+  def listAllRepositories(requester: Option[Account])(
+      ordering: VcsMetadataRepositoriesOrdering
+  ): Stream[F, VcsRepository]
+
+  /** Return a list of all repositories of the given owner.
+    *
+    * @param requester
+    *   An optional account (None if guest i.e. not logged in user) for whom the list shall be created. This
+    *   will affect which repositories will be returned in regard to access rights.
+    * @param owner
+    *   Data about the owner of the repositories containing information needed to query the database.
+    * @return
+    *   A stream of vcs repository entries ordered by name which may be empty.
+    */
+  def listRepositories(requester: Option[Account])(owner: VcsRepositoryOwner): Stream[F, VcsRepository]
+
+}
+
+/** Helper types to provide sorting instructions to several functions.
+  */
+enum VcsMetadataRepositoriesOrdering {
+  case NameAscending  extends VcsMetadataRepositoriesOrdering
+  case NameDescending extends VcsMetadataRepositoriesOrdering
+}
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-02-02 16:56:14.778979205 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-02-02 16:56:14.782979214 +0000
@@ -33,11 +33,16 @@
   *   The configuration for darcs related operations.
   * @param darcs
   *   A class providing darcs VCS operations.
+  * @param vcsMetadataRepo
+  *   A repository for handling database operations regarding our vcs repositories and their metadata.
   * @tparam F
   *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
   */
-final class VcsRepositoryRoutes[F[_]: Async](config: DarcsConfiguration, darcs: DarcsCommands[F])
-    extends Http4sDsl[F] {
+final class VcsRepositoryRoutes[F[_]: Async](
+    config: DarcsConfiguration,
+    darcs: DarcsCommands[F],
+    vcsMetadataRepo: VcsMetadataRepository[F]
+) extends Http4sDsl[F] {
   private val log = LoggerFactory.getLogger(getClass)
 
   private val createRepoPath  = uri"/repo/create"
@@ -105,16 +110,47 @@
                 directory <- Sync[F].delay(
                   Paths.get(config.repositoriesDirectory.toPath.toString, user.name.toString)
                 )
-                _ <- Sync[F].delay {
-                  if (!Files.exists(directory)) {
-                    log.debug(s"User repository directory does not exist, trying to create it: $directory")
-                    Files.createDirectories(directory)
-                  }
+                repoInDb <- vcsMetadataRepo.findVcsRepository(
+                  user.toVcsRepositoryOwner,
+                  newVcsRepository.name
+                )
+                output <- repoInDb match {
+                  case None =>
+                    for {
+                      _ <- Sync[F].delay {
+                        if (repoInDb.isEmpty && !Files.exists(directory)) {
+                          log.debug(
+                            s"User repository directory does not exist, trying to create it: $directory"
+                          )
+                          Files.createDirectories(directory)
+                        }
+                      }
+                      repoMetadata = VcsRepository(
+                        newVcsRepository.name,
+                        user.toVcsRepositoryOwner,
+                        newVcsRepository.isPrivate,
+                        newVcsRepository.description,
+                        newVcsRepository.website
+                      )
+                      output <- darcs.initialize(directory)(newVcsRepository.name.toString)(Chain.empty)
+                      _ <-
+                        if (output.exitValue === 0)
+                          vcsMetadataRepo.createVcsRepository(repoMetadata)
+                        else
+                          Sync[F].pure(0) // Do not create DB entry if darcs init failed!
+                    } yield output
+                  case Some(_) =>
+                    Sync[F].delay(DarcsCommandOutput(1, Chain.empty, Chain("The repository already exists!")))
                 }
-                output <- darcs.initialize(directory)(newVcsRepository.name.toString)(Chain.empty)
                 resp <- output.exitValue match {
                   case 0 =>
-                    SeeOther.apply(Location(Uri(path = Uri.Path.Root)))
+                    SeeOther.apply(
+                      Location(
+                        Uri(path =
+                          Uri.Path.Root |+| Uri.Path(Vector(Uri.Path.Segment(s"~${user.name.toString}")))
+                        )
+                      )
+                    )
                   case _ =>
                     for {
                       _ <- Sync[F].delay(
@@ -133,6 +169,36 @@
       }
   }
 
+  private val showAllRepositories: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ GET -> Root / "projects" as user =>
+      for {
+        csrf <- Sync[F].delay(ar.req.getCsrfToken)
+        repos <- vcsMetadataRepo
+          .listAllRepositories(user.some)(VcsMetadataRepositoriesOrdering.NameAscending)
+          .compile
+          .toList
+        actionBaseUri <- Sync[F].delay(uri"projects")
+        resp <- Ok.apply(
+          views.html.showAllRepositories()(actionBaseUri, csrf, s"Smederee - Projects".some, user.some)(repos)
+        )
+      } yield resp
+  }
+
+  private val showAllRepositoriesForGuests: HttpRoutes[F] = HttpRoutes.of {
+    case req @ GET -> Root / "projects" =>
+      for {
+        csrf <- Sync[F].delay(req.getCsrfToken)
+        repos <- vcsMetadataRepo
+          .listAllRepositories(None)(VcsMetadataRepositoriesOrdering.NameAscending)
+          .compile
+          .toList
+        actionBaseUri <- Sync[F].delay(uri"projects")
+        resp <- Ok.apply(
+          views.html.showAllRepositories()(actionBaseUri, csrf, s"Smederee - Projects".some, None)(repos)
+        )
+      } yield resp
+  }
+
   private val showCreateRepositoryForm: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ GET -> Root / "repo" / "create" as user =>
       for {
@@ -145,38 +211,38 @@
   }
 
   private val showRepositories: AuthedRoutes[Account, F] = AuthedRoutes.of {
-    case ar @ GET -> Root / UsernamePathParameter(repositoriesOwner) as user =>
+    case ar @ GET -> Root / UsernamePathParameter(repositoriesOwnerName) as user =>
       for {
-        csrf <- Sync[F].delay(ar.req.getCsrfToken)
-        directory <- Sync[F].delay(
-          os.Path(
-            Paths.get(
-              config.repositoriesDirectory.toPath.toString,
-              repositoriesOwner.toString
-            )
-          )
-        )
-        listing <- Sync[F].delay(
-          os.walk
-            .attrs(directory, skip = (path, _) => !os.isDir(path), maxDepth = 1)
-            .map((path, attrs) => (path.relativeTo(directory), attrs))
-            .sortBy(_._1)
-        )
+        csrf  <- Sync[F].delay(ar.req.getCsrfToken)
+        owner <- vcsMetadataRepo.findVcsRepositoryOwner(repositoriesOwnerName)
+        repos <- owner.traverse(owner => vcsMetadataRepo.listRepositories(user.some)(owner).compile.toList)
         actionBaseUri <- Sync[F].delay(
           Uri(path =
             Uri.Path.Root |+| Uri.Path(
-              Vector(Uri.Path.Segment(s"~$repositoriesOwner"))
+              Vector(Uri.Path.Segment(s"~$repositoriesOwnerName"))
             )
           )
         )
-        resp <- Ok.apply(
-          views.html.showRepositories()(
-            actionBaseUri,
-            csrf,
-            s"Smederee/~$repositoriesOwner".some,
-            user
-          )(listing, repositoriesOwner)
-        )
+        resp <- owner match {
+          case None => // TODO Better error message...
+            NotFound.apply(
+              views.html.showRepositories()(
+                actionBaseUri,
+                csrf,
+                s"Smederee/~$repositoriesOwnerName".some,
+                user
+              )(repos.getOrElse(List.empty), repositoriesOwnerName)
+            )
+          case Some(_) =>
+            Ok.apply(
+              views.html.showRepositories()(
+                actionBaseUri,
+                csrf,
+                s"Smederee/~$repositoriesOwnerName".some,
+                user
+              )(repos.getOrElse(List.empty), repositoriesOwnerName)
+            )
+        }
       } yield resp
   }
 
@@ -334,6 +400,8 @@
   }
 
   val protectedRoutes =
-    showRepositories <+> parseCreateRepositoryForm <+> showCreateRepositoryForm <+> showRepositoryHistory <+> showRepositoryFiles <+> showRepository
+    showAllRepositories <+> showRepositories <+> parseCreateRepositoryForm <+> showCreateRepositoryForm <+> showRepositoryHistory <+> showRepositoryFiles <+> showRepository
+
+  val routes = showAllRepositoriesForGuests
 
 }
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-02-02 16:56:14.778979205 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala	2025-02-02 16:56:14.782979214 +0000
@@ -9,6 +9,7 @@
 
 package de.smederee.hub
 
+import cats._
 import cats.data._
 import cats.syntax.all._
 import org.http4s.Uri
@@ -42,6 +43,13 @@
 opaque type VcsRepositoryName = String
 object VcsRepositoryName {
 
+  given Eq[VcsRepositoryName] = Eq.fromUniversalEquals
+
+  given Order[VcsRepositoryName] = Order.from((a, b) => a.toString.compareTo(b.toString))
+
+  // TODO Can we rewrite this in a Scala-3 way (i.e. without implicitly)?
+  given Ordering[VcsRepositoryName] = implicitly[Order[VcsRepositoryName]].toOrdering
+
   val Format: Regex = "^[a-zA-Z0-9][a-zA-Z0-9\\-_]{1,63}$".r
 
   /** Create an instance of VcsRepositoryName from the given String type.
@@ -101,6 +109,21 @@
   def unapply(str: String): Option[VcsRepositoryName] = Option(str).flatMap(VcsRepositoryName.from)
 }
 
+/** Descriptive information about the owner of a vcs repository.
+  *
+  * @param owner
+  *   The user ID of the account that owns the repository.
+  * @param name
+  *   The name of the account that owns the repository.
+  */
+final case class VcsRepositoryOwner(uid: UserId, name: Username)
+
+object VcsRepositoryOwner {
+
+  given Eq[VcsRepositoryOwner] = Eq.fromUniversalEquals
+
+}
+
 /** Data about a VCS respository.
   *
   * @param name
@@ -108,7 +131,7 @@
   *   alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64
   *   characters long.
   * @param owner
-  *   The account that owns the repository.
+  *   The owner of the repository.
   * @param isPrivate
   *   A flag indicating if this repository is private i.e. only visible / accessible for accounts with
   *   appropriate permissions.
@@ -119,8 +142,12 @@
   */
 final case class VcsRepository(
     name: VcsRepositoryName,
-    owner: Account,
+    owner: VcsRepositoryOwner,
     isPrivate: Boolean,
     description: Option[VcsRepositoryDescription],
     website: Option[Uri]
 )
+
+object VcsRepository {
+  given Eq[VcsRepository] = Eq.fromUniversalEquals
+}
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-02-02 16:56:14.778979205 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/createRepository.scala.html	2025-02-02 16:56:14.782979214 +0000
@@ -9,7 +9,7 @@
         <div class="form-errors">
           @formErrors.get(fieldGlobal).map { es =>
             @for(error <- es) {
-              <p class="alert alert-danger">
+              <p class="alert alert-error">
                 <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
                 <span class="sr-only">Fehler:</span>
                 @error
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/login.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/login.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/login.scala.html	2025-02-02 16:56:14.778979205 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/login.scala.html	2025-02-02 16:56:14.782979214 +0000
@@ -9,7 +9,7 @@
       <div class="form-errors">
         @formErrors.get(fieldGlobal).map { es =>
         @for(error <- es) {
-        <p class="alert alert-danger">
+        <p class="alert alert-error">
         <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
         <span class="sr-only">Fehler:</span>
         @error
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html	2025-02-02 16:56:14.778979205 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html	2025-02-02 16:56:14.782979214 +0000
@@ -4,6 +4,7 @@
   <a class="logo pure-menu-heading" href="@pathPrefix/">@Messages("global.navbar.top.logo")</a>
 
   <ul class="pure-menu-list">
+    <li class="pure-menu-item"><a class="pure-menu-link" href="@pathPrefix/projects">Projects</a></li>
     @if(user.nonEmpty) {
       @for(account <- user) {
         <li class="pure-menu-item"><a class="pure-menu-link" href="@pathPrefix/~@account.name">Your repositories</a></li>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showAllRepositories.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showAllRepositories.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showAllRepositories.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showAllRepositories.scala.html	2025-02-02 16:56:14.782979214 +0000
@@ -0,0 +1,37 @@
+@(lang: LanguageCode = LanguageCode("en"), pathPrefix: Option[Uri] = None)(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Option[Account])(listing: List[VcsRepository])
+@main(lang, pathPrefix)()(csrf, title, user) {
+@defining(lang.toLocale) { implicit locale =>
+  <div class="content">
+    <div class="pure-g">
+      <div class="l-box pure-u-1-1 pure-u-md-1-1">
+        @if(listing.nonEmpty) {
+        <table class="pure-table pure-table-horizontal">
+          <thead>
+            <tr>
+              <th></th>
+              <th>Name</th>
+              <th>Description</th>
+            </tr>
+          </thead>
+          <tbody>
+            @for(repo <- listing) {
+            <tr>
+              <td><i class="fa-solid fa-folder-tree"></i> @if(repo.isPrivate){<i class="fa-solid fa-lock"></i>}else{ }</td>
+              <td>
+                <a href="@createFullPath(pathPrefix)(Uri(path = Uri.Path.Root).addSegment(s"~${repo.owner.name.toString}"))">~@{repo.owner.name}</a>
+                /
+                <a href="@createFullPath(pathPrefix)(Uri(path = Uri.Path.Root).addSegment(s"~${repo.owner.name.toString}").addSegment(repo.name.toString))">@{repo.name}</a>
+              </td>
+              <td>@repo.description</td>
+            </tr>
+            }
+          </tbody>
+        </table>
+        } else {
+          <div class="alert alert-warning">No repositories found.</div>
+        }
+      </div>
+    </div>
+  </div>
+}
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html	2025-02-02 16:56:14.782979214 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html	2025-02-02 16:56:14.782979214 +0000
@@ -1,29 +1,31 @@
-@(lang: LanguageCode = LanguageCode("en"), pathPrefix: Option[Uri] = None)(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(listing: IndexedSeq[(os.RelPath, os.StatInfo)], repositoriesOwner: Username)
+@(lang: LanguageCode = LanguageCode("en"), pathPrefix: Option[Uri] = None)(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(listing: List[VcsRepository], repositoriesOwner: Username)
 @main(lang, pathPrefix)()(csrf, title, user.some) {
 @defining(lang.toLocale) { implicit locale =>
   <div class="content">
     <div class="pure-g">
       <div class="l-box pure-u-1-1 pure-u-md-1-1">
+        @if(listing.nonEmpty) {
         <table class="pure-table pure-table-horizontal">
           <thead>
             <tr>
               <th></th>
               <th>Name</th>
-              <th>Size</th>
-              <th>Modified</th>
+              <th>Description</th>
             </tr>
           </thead>
           <tbody>
-            @for(entry <- listing) {
+            @for(repo <- listing) {
             <tr>
-              <td><i class="fa-solid fa-folder-tree"></i></td>
-              <td><a href="@createFullPath(pathPrefix)(actionBaseUri.addSegment(entry._1.last))">@{entry._1}</a></td>
-              <td>@{entry._2.size}</td>
-              <td>@{entry._2.mtime}</td>
+              <td><i class="fa-solid fa-folder-tree"></i> @if(repo.isPrivate){<i class="fa-solid fa-lock"></i>}else{ }</td>
+              <td><a href="@createFullPath(pathPrefix)(actionBaseUri.addSegment(repo.name.toString))">@{repo.name}</a></td>
+              <td>@repo.description</td>
             </tr>
             }
           </tbody>
         </table>
+        } else {
+          <div class="alert">No repositories found.</div>
+        }
       </div>
     </div>
   </div>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/signup.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/signup.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/signup.scala.html	2025-02-02 16:56:14.782979214 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/signup.scala.html	2025-02-02 16:56:14.782979214 +0000
@@ -9,7 +9,7 @@
         <div class="form-errors">
           @formErrors.get(fieldGlobal).map { es =>
             @for(error <- es) {
-              <p class="alert alert-danger">
+              <p class="alert alert-error">
                 <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
                 <span class="sr-only">Fehler:</span>
                 @error
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-02-02 16:56:14.782979214 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-02-02 16:56:14.782979214 +0000
@@ -13,6 +13,7 @@
 import java.time._
 import java.util.{ Locale, UUID }
 
+import cats.syntax.all._
 import de.smederee.security.{ PrivateKey, SignAndValidate }
 
 import org.scalacheck._
@@ -95,6 +96,10 @@
 
   given Arbitrary[Account] = Arbitrary(genValidAccount)
 
+  val genValidAccounts: Gen[List[Account]] = Gen
+    .nonEmptyListOf(genValidAccount)
+    .suchThat(accounts => accounts.size === accounts.map(_.name).distinct.size) // Ensure unique names.
+
   val genValidSession: Gen[Session] =
     for {
       id  <- genSessionId
@@ -152,4 +157,18 @@
     )
     .map(cs => VcsRepositoryName(cs.take(64).mkString))
 
+  val genValidVcsRepositoryOwner = for {
+    uid  <- genUserId
+    name <- genValidUsername
+  } yield VcsRepositoryOwner(uid, name)
+
+  val genValidVcsRepository: Gen[VcsRepository] = for {
+    name        <- genValidVcsRepositoryName
+    owner       <- genValidVcsRepositoryOwner
+    isPrivate   <- Gen.oneOf(List(false, true))
+    description <- Gen.alphaNumStr.map(VcsRepositoryDescription.from)
+  } yield VcsRepository(name, owner, isPrivate, description, None)
+
+  val genValidVcsRepositories: Gen[List[VcsRepository]] = Gen.nonEmptyListOf(genValidVcsRepository)
+
 }