~jan0sch/smederee

Showing details for patch 2044b7d9feaed2f20029ff775bfa08cd169b7e12.
2023-12-31 (Sun), 6:02 PM - Jens Grassel - 2044b7d9feaed2f20029ff775bfa08cd169b7e12

hub: Add organisations

Add a model and the related database structure for organisations. Organisations
are supposed to be able to own repositories and have teams with varying
permissions. Therefore some restrictions for user accounts also apply to
organisations.

An organisation has a dedicated owner and may have administrators that are
supposed to be able to have full permissions on it.

This is only rudimentary support but should work as a base to extend things
further down the road.
Summary of changes
10 files added
  • modules/hub/src/main/resources/db/migration/hub/V7__organisations_table.sql
  • modules/hub/src/main/scala/de/smederee/hub/DoobieOrganisationRepository.scala
  • modules/hub/src/main/scala/de/smederee/hub/Organisation.scala
  • modules/hub/src/main/scala/de/smederee/hub/OrganisationForm.scala
  • modules/hub/src/main/scala/de/smederee/hub/OrganisationRepository.scala
  • modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala
  • modules/hub/src/main/twirl/de/smederee/hub/views/createOrganisation.scala.html
  • modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisation.scala.html
  • modules/hub/src/main/twirl/de/smederee/hub/views/forms/organisationFormFields.scala.html
  • modules/hub/src/test/scala/de/smederee/hub/DoobieOrganisationRepositoryTest.scala
7 files modified with 83 lines added and 17 lines removed
  • modules/hub/src/main/resources/messages.properties with 20 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 5 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 29 added and 13 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 3 added and 1 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/Generators.scala with 22 added and 0 removed lines
  • modules/security/src/main/scala/de/smederee/security/UserId.scala with 3 added and 1 removed lines
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/hub/V7__organisations_table.sql new-smederee/modules/hub/src/main/resources/db/migration/hub/V7__organisations_table.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/hub/V7__organisations_table.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/hub/V7__organisations_table.sql	2025-01-13 07:38:10.472647097 +0000
@@ -0,0 +1,51 @@
+CREATE TABLE "hub"."organisations"
+(
+  "id"          UUID                     NOT NULL,
+  "name"        CHARACTER VARYING(32)    NOT NULL,
+  "owner"       UUID                     NOT NULL,
+  "full_name"   CHARACTER VARYING(128),
+  "description" CHARACTER VARYING(254),
+  "is_private"  BOOLEAN                  NOT NULL DEFAULT FALSE,
+  "website"     TEXT,
+  "created_at"  TIMESTAMP WITH TIME ZONE NOT NULL,
+  "updated_at"  TIMESTAMP WITH TIME ZONE NOT NULL,
+  CONSTRAINT "organisations_pk"          PRIMARY KEY ("id"),
+  CONSTRAINT "organisations_unique_name" UNIQUE ("name"),
+  CONSTRAINT "organisations_fk_uid" FOREIGN KEY ("owner")
+    REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+CREATE INDEX "organisations_private" ON "hub"."organisations" ("is_private");
+
+COMMENT ON TABLE "hub"."organisations" IS 'All organisations live within this table.';
+COMMENT ON COLUMN "hub"."organisations"."id" IS 'A globally unique ID for the organisation.';
+COMMENT ON COLUMN "hub"."organisations"."name" IS 'A name (like a username) between 2 and 32 characters which must be globally unique, contain only lowercase alphanumeric characters and start with a character.';
+COMMENT ON COLUMN "hub"."organisations"."owner" IS 'The unique ID of the user account owning the organisation.';
+COMMENT ON COLUMN "hub"."organisations"."full_name" IS 'An optional full name for the organisation.';
+COMMENT ON COLUMN "hub"."organisations"."description" IS 'An optional description of the organisation.';
+COMMENT ON COLUMN "hub"."organisations"."is_private" IS 'A flag indicating if this organisation is private i.e. only visible / accessible for accounts with appropriate permissions.';
+COMMENT ON COLUMN "hub"."organisations"."website" IS 'An optional uri pointing to a website related to the organisation.';
+COMMENT ON COLUMN "hub"."organisations"."created_at" IS 'The timestamp of when the organisation was created.';
+COMMENT ON COLUMN "hub"."organisations"."updated_at" IS 'A timestamp when the organisation was last changed.';
+
+CREATE TABLE "hub"."organisation_admins"
+(
+  "organisation" UUID NOT NULL,
+  "admin"         UUID NOT NULL,
+  CONSTRAINT "organisation_admins_pk"     PRIMARY KEY ("organisation", "admin"),
+  CONSTRAINT "organisation_admins_fk_oid" FOREIGN KEY ("organisation")
+    REFERENCES "hub"."organisations" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
+  CONSTRAINT "organisation_admins_fk_uid" FOREIGN KEY ("admin")
+    REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE "hub"."organisation_admins" IS 'A mapping table to allow multiple users administering an organisation.';
+COMMENT ON COLUMN "hub"."organisation_admins"."organisation" IS 'The globally unique ID of the organisation.';
+COMMENT ON COLUMN "hub"."organisation_admins"."admin" IS 'The unique ID of one of the user accounts allowed to administer the organisation.';
+
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-13 07:38:10.468647086 +0000
+++ new-smederee/modules/hub/src/main/resources/messages.properties	2025-01-13 07:38:10.472647097 +0000
@@ -38,6 +38,24 @@
 form.change-password.username.help=Please enter the username for your account.
 form.change-password.username.placeholder=Please enter your username.
 form.change-password.username=Username
+form.organisation.button.create.submit=Create organisation
+form.organisation.button.edit.submit=Save changes
+form.organisation.name=Name
+form.organisation.name.placeholder=Please enter a organisation name.
+form.organisation.name.help=It must start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and be all lowercase.
+form.organisation.owner=Owner
+form.organisation.owner.help=You may change the primary owner of the organisation here.
+form.organisation.full-name=Full Name
+form.organisation.full-name.placeholder=
+form.organisation.full-name.help=The full name of an organisation is allowed to be more verbose and less restricitive but it must not exceed 128 characters.
+form.organisation.is-private=Private Organisation
+form.organisation.is-private.help=A private organisation can only be accessed by the owner and accounts which have been given permissions to do so.
+form.organisation.description=Description
+form.organisation.description.placeholder=
+form.organisation.description.help=An optional short description of your organisation.
+form.organisation.website=Website
+form.organisation.website.placeholder=https://example.com
+form.organisation.website.help=An optional URI pointing to a website.
 form.create-repo.button.submit=Create repository
 form.create-repo.name=Name
 form.create-repo.name.placeholder=Please enter a repository name.
@@ -46,7 +64,7 @@
 form.create-repo.is-private.help=A private repository can only be accessed by the owner and accounts which have been given permissions to do so.
 form.create-repo.description=Description
 form.create-repo.description.placeholder=
-form.create-repo.description.help=An optional short description of you repo / project.
+form.create-repo.description.help=An optional short description of your 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
@@ -106,6 +124,7 @@
 global.login=Login
 global.logout=Logout
 global.navbar.top.logo=Smederee
+global.navbar.top.organisation.new=New organisation
 global.navbar.top.repositories.all=All repositories
 global.navbar.top.repositories.yours=Your repositories
 global.navbar.top.repository.new=New repository
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieOrganisationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieOrganisationRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieOrganisationRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieOrganisationRepository.scala	2025-01-13 07:38:10.472647097 +0000
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import java.util.UUID
+
+import cats.effect.*
+import de.smederee.email.EmailAddress
+import de.smederee.i18n.LanguageCode
+import de.smederee.security.*
+import doobie.Fragments.*
+import doobie.*
+import doobie.implicits.*
+import doobie.postgres.implicits.*
+import fs2.Stream
+import org.http4s.Uri
+
+final class DoobieOrganisationRepository[F[_]: Sync](tx: Transactor[F]) extends OrganisationRepository[F] {
+    given Meta[EmailAddress]            = Meta[String].timap(EmailAddress.apply)(_.toString)
+    given Meta[FullName]                = Meta[String].timap(FullName.apply)(_.toString)
+    given Meta[LanguageCode]            = Meta[String].timap(LanguageCode.apply)(_.toString)
+    given Meta[OrganisationDescription] = Meta[String].timap(OrganisationDescription.apply)(_.toString)
+    given Meta[OrganisationId]          = Meta[UUID].timap(OrganisationId.apply)(_.toUUID)
+    given Meta[PasswordHash]            = Meta[String].timap(PasswordHash.apply)(_.toString)
+    given Meta[SessionId]               = Meta[String].timap(SessionId.apply)(_.toString)
+    given Meta[UnlockToken]             = Meta[String].timap(UnlockToken.apply)(_.toString)
+    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 selectOrganisationColumns =
+        fr"""SELECT id, name, owner, full_name, description, is_private, website FROM hub.organisations AS organisations"""
+
+    override def addAdministrator(organisationId: OrganisationId)(user: UserId): F[Int] =
+        sql"""INSERT INTO "hub"."organisation_admins" (
+                organisation,
+                admin
+              ) VALUES (
+                $organisationId,
+                $user
+              ) ON CONFLICT (organisation, admin) DO NOTHING""".update.run
+            .transact(tx)
+
+    override def allByOwner(owner: UserId): Stream[F, Organisation] = {
+        val ownerFilter = fr"""owner = $owner"""
+        val sqlQuery    = selectOrganisationColumns ++ whereAnd(ownerFilter)
+        sqlQuery.query[Organisation].stream.transact(tx)
+    }
+
+    override def create(org: Organisation): F[Int] =
+        sql"""INSERT INTO "hub"."organisations" (
+                id,
+                name,
+                owner,
+                full_name,
+                description,
+                is_private,
+                website,
+                created_at,
+                updated_at
+              ) VALUES (
+                ${org.oid},
+                ${org.name},
+                ${org.owner},
+                ${org.fullName},
+                ${org.description},
+                ${org.isPrivate},
+                ${org.website},
+                NOW(),
+                NOW()
+              )""".update.run.transact(tx)
+
+    override def delete(organisationId: OrganisationId): F[Int] =
+        sql"""DELETE FROM "hub"."organisations" WHERE id = $organisationId""".update.run.transact(tx)
+
+    override def find(organisationId: OrganisationId): F[Option[Organisation]] = {
+        val idFilter = fr"""id = $organisationId"""
+        val sqlQuery = selectOrganisationColumns ++ whereAnd(idFilter)
+        sqlQuery.query[Organisation].option.transact(tx)
+    }
+
+    override def findByName(name: Username): F[Option[Organisation]] = {
+        val nameFilter = fr"""organisations.name = $name"""
+        val sqlQuery   = selectOrganisationColumns ++ whereAnd(nameFilter)
+        sqlQuery.query[Organisation].option.transact(tx)
+    }
+
+    override def findOwner(organisationId: OrganisationId): F[Option[Account]] = {
+        val organisationFilter = fr"""organisations.id = $organisationId"""
+        val sqlQuery =
+            fr"""SELECT
+                   accounts.uid,
+                   accounts.name,
+                   accounts.email,
+                   accounts.full_name,
+                   accounts.validated_email,
+                   accounts.language
+                 FROM hub.accounts AS accounts
+                 JOIN hub.organisations AS organisations
+                 ON accounts.uid = organisations.owner""" ++ whereAnd(organisationFilter)
+        sqlQuery.query[Account].option.transact(tx)
+    }
+
+    override def getAdministrators(organisationId: OrganisationId): Stream[F, Account] = {
+        val organisationFilter = fr"""admins.organisation = $organisationId"""
+        val sqlQuery =
+            fr"""SELECT
+                   accounts.uid,
+                   accounts.name,
+                   accounts.email,
+                   accounts.full_name,
+                   accounts.validated_email,
+                   accounts.language
+                 FROM hub.accounts AS accounts
+                 JOIN hub.organisation_admins AS admins
+                 ON accounts.uid = admins.admin""" ++ whereAnd(organisationFilter)
+        sqlQuery.query[Account].stream.transact(tx)
+    }
+
+    override def removeAdministrator(organisationId: OrganisationId)(user: UserId): F[Int] =
+        sql"""DELETE FROM "hub"."organisation_admins" WHERE organisation = $organisationId AND admin = $user""".update.run
+            .transact(tx)
+
+    override def update(org: Organisation): F[Int] =
+        sql"""UPDATE "hub"."organisations" SET
+                name = ${org.name},
+                owner = ${org.owner},
+                full_name = ${org.fullName},
+                description = ${org.description},
+                is_private = ${org.isPrivate},
+                website = ${org.website},
+                updated_at = NOW()
+              WHERE id = ${org.oid}""".update.run.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-01-13 07:38:10.468647086 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-13 07:38:10.472647097 +0000
@@ -490,12 +490,15 @@
                     signUpRepo      = new DoobieSignupRepository[IO](hubTransactor)
                     signUpRoutes    = new SignupRoutes[IO](hubConfiguration.service, signUpRepo)
                     landingPages    = new LandingPageRoutes[IO](hubConfiguration.service)
+                    orgRepo         = new DoobieOrganisationRepository[IO](hubTransactor)
+                    orgRoutes       = new OrganisationRoutes[IO](hubConfiguration.service, orgRepo)
                     vcsMetadataRepo = new DoobieVcsMetadataRepository[IO](hubTransactor)
                     vcsRepoRoutes = new VcsRepositoryRoutes[IO](
                         hubConfiguration.service,
                         darcsWrapper,
                         vcsMetadataRepo,
-                        ticketProjectsRepo
+                        ticketProjectsRepo,
+                        orgRepo
                     )
                     protectedRoutesWithFallThrough = authenticationWithFallThrough(
                         authenticationRoutes.protectedRoutes <+>
@@ -505,6 +508,7 @@
                             ticketLabelRoutes.protectedRoutes <+>
                             ticketMilestoneRoutes.protectedRoutes <+>
                             ticketRoutes.protectedRoutes <+>
+                            orgRoutes.protectedRoutes <+>
                             vcsRepoRoutes.protectedRoutes <+>
                             landingPages.protectedRoutes
                     )
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationForm.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationForm.scala	2025-01-13 07:38:10.472647097 +0000
@@ -0,0 +1,169 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import cats.data.*
+import cats.syntax.all.*
+import de.smederee.hub.forms.*
+import de.smederee.hub.forms.types.*
+import de.smederee.security.*
+import org.http4s.Uri
+
+/** Data container for a form to create or edit an [[Organisation]].
+  *
+  * @param name
+  *   A unique name which will be used in urls.
+  * @param owner
+  *   The globally unique ID of the user owning the organisation.
+  * @param fullName
+  *   An optional full name for the organisation.
+  * @param description
+  *   An optional description of the organisation.
+  * @param isPrivate
+  *   A flag indicating if this organisation is private i.e. only visible / accessible for accounts with appropriate
+  *   permissions.
+  * @param website
+  *   An optional uri pointing to a website related to the organisation.
+  */
+final case class OrganisationForm(
+    name: Username,
+    owner: Option[UserId],
+    fullName: Option[FullName],
+    description: Option[OrganisationDescription],
+    isPrivate: Boolean,
+    website: Option[Uri]
+)
+
+object OrganisationForm extends FormValidator[OrganisationForm] {
+    val fieldDescription: FormField = FormField("description")
+    val fieldFullName: FormField    = FormField("full_name")
+    val fieldIsPrivate: FormField   = FormField("is_private")
+    val fieldName: FormField        = FormField("name")
+    val fieldOwner: FormField       = FormField("owner")
+    val fieldWebsite: FormField     = FormField("website")
+
+    /** Create a form for editing an organisation from the given organisation data.
+      *
+      * @param organisation
+      *   The organisation that provides the source data for the form.
+      * @return
+      *   A form filled with the data from the given organisation.
+      */
+    def from(organisation: Organisation): OrganisationForm =
+        OrganisationForm(
+            name = organisation.name,
+            owner = organisation.owner.some,
+            fullName = organisation.fullName,
+            description = organisation.description,
+            isPrivate = organisation.isPrivate,
+            website = organisation.website
+        )
+
+    override def validate(data: Map[String, String]): ValidatedNec[FormErrors, OrganisationForm] = {
+        val name = data
+            .get(fieldName)
+            .fold(FormFieldError("No name given!").invalidNec)(s =>
+                Username.from(s).fold(FormFieldError("Invalid name!").invalidNec)(_.validNec)
+            )
+            .leftMap(es => NonEmptyChain.of(Map(fieldName -> es.toList)))
+        val owner = data
+            .get(fieldOwner)
+            .fold(None.validNec)(s =>
+                UserId.fromString(s) match {
+                    case Left(error) => FormFieldError(error).invalidNec
+                    case Right(id)   => id.some.validNec
+                }
+            )
+            .leftMap(es => NonEmptyChain.of(Map(fieldName -> es.toList)))
+        val fullName = data
+            .get(fieldFullName)
+            .filter(_.nonEmpty)
+            .fold(None.validNec)(s =>
+                FullName.from(s).fold(FormFieldError("Invalid full organisation name!").invalidNec)(_.some.validNec)
+            )
+            .leftMap(es => NonEmptyChain.of(Map(fieldName -> es.toList)))
+        val description = data
+            .get(fieldDescription)
+            .filter(_.nonEmpty)
+            .fold(None.validNec)(s =>
+                OrganisationDescription
+                    .from(s)
+                    .fold(FormFieldError("Invalid organisation description!").invalidNec)(_.some.validNec)
+            )
+            .leftMap(es => NonEmptyChain.of(Map(fieldDescription -> es.toList)))
+        val privateFlag: ValidatedNec[FormErrors, Boolean] =
+            data.get(fieldIsPrivate).fold(false.validNec)(s => s.matches("true").validNec)
+        val website = data
+            .get(fieldWebsite)
+            .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, owner, fullName, description, privateFlag, website).mapN {
+            case (name, owner, fullName, description, isPrivate, website) =>
+                OrganisationForm(
+                    name = name,
+                    owner = owner,
+                    fullName = fullName,
+                    description = description,
+                    isPrivate = isPrivate,
+                    website = website
+                )
+        }
+    }
+
+    extension (form: OrganisationForm) {
+
+        /** Convert the form class into a stringified map which is used as underlying data type for form handling in the
+          * twirl templating library.
+          *
+          * @return
+          *   A stringified map containing the data of the form.
+          */
+        def toMap: Map[String, String] = {
+            val name  = Map(OrganisationForm.fieldName.toString -> form.name.toString)
+            val owner = form.owner.fold(Map.empty)(owner => Map(OrganisationForm.fieldOwner.toString -> owner.toString))
+            val fullName = form.fullName.fold(Map.empty)(fullName =>
+                Map(OrganisationForm.fieldFullName.toString -> fullName.toString)
+            )
+            val description = form.description.fold(Map.empty)(description =>
+                Map(OrganisationForm.fieldDescription.toString -> description.toString)
+            )
+            val isPrivate =
+                if (form.isPrivate) {
+                    Map(OrganisationForm.fieldIsPrivate.toString -> "true")
+                } else {
+                    Map(OrganisationForm.fieldIsPrivate.toString -> "false")
+                }
+            val website =
+                form.website.fold(Map.empty)(website => Map(OrganisationForm.fieldWebsite.toString -> website.toString))
+            name ++ owner ++ fullName ++ description ++ isPrivate ++ website
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRepository.scala	2025-01-13 07:38:10.472647097 +0000
@@ -0,0 +1,124 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import de.smederee.security.UserId
+import de.smederee.security.Username
+import fs2.Stream
+
+/** A base class for a database repository that should handle all organisation related functionality.
+  *
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
+  */
+abstract class OrganisationRepository[F[_]] {
+
+    /** Add an administrator to the organisation.
+      *
+      * @param organisationId
+      *   The unique ID of the organisation.
+      * @param user
+      *   The globally unique ID of the user to be added as an administrator.
+      * @return
+      *   The number of affected database rows.
+      */
+    def addAdministrator(organisationId: OrganisationId)(user: UserId): F[Int]
+
+    /** Find all organisations of which the user with the given id is an owner.
+      *
+      * @param owner
+      *   The unique user id of an account.
+      * @return
+      *   A stream with all organisation of which the user is an owner.
+      */
+    def allByOwner(owner: UserId): Stream[F, Organisation]
+
+    /** Create the database entry for the given organisation.
+      *
+      * @param org
+      *   The organisation that shall be written to the database.
+      * @return
+      *   The number of affected database rows.
+      */
+    def create(org: Organisation): F[Int]
+
+    /** Delete the organisation with the given id.
+      *
+      * @param organisationId
+      *   The unique ID of the organisation.
+      * @return
+      *   The number of affected database rows.
+      */
+    def delete(organisationId: OrganisationId): F[Int]
+
+    /** Find the organisation with the given name.
+      *
+      * @param organisationId
+      *   The unique ID of the organisation.
+      * @return
+      *   An option to the found organisation.
+      */
+    def find(organisationId: OrganisationId): F[Option[Organisation]]
+
+    /** Find the organisation with the given name.
+      *
+      * @param name
+      *   An organisation name which is actually a valid username.
+      * @return
+      *   An option to the found organisation.
+      */
+    def findByName(name: Username): F[Option[Organisation]]
+
+    /** Find the account of an organisation owner.
+      *
+      * @param organisationId
+      *   The unique ID of the organisation.
+      * @return
+      *   An option to the found account.
+      */
+    def findOwner(organisationId: OrganisationId): F[Option[Account]]
+
+    /** Return the accounts of all users that are administrators of the given organisation.
+      *
+      * @param organisationId
+      *   The unique ID of the organisation.
+      * @return
+      *   A stream of accounts that may be empty.
+      */
+    def getAdministrators(organisationId: OrganisationId): Stream[F, Account]
+
+    /** Remove an administrator from the organisation.
+      *
+      * @param organisationId
+      *   The unique ID of the organisation.
+      * @param user
+      *   The globally unique ID of the user to be removed.
+      * @return
+      *   The number of affected database rows.
+      */
+    def removeAdministrator(organisationId: OrganisationId)(user: UserId): F[Int]
+
+    /** Update the database entry for the given organisation.
+      *
+      * @param org
+      *   The organisation that shall be written to the database.
+      * @return
+      *   The number of affected database rows.
+      */
+    def update(org: Organisation): F[Int]
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala	2025-01-13 07:38:10.472647097 +0000
@@ -0,0 +1,305 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import cats.*
+import cats.data.*
+import cats.effect.*
+import cats.syntax.all.*
+import de.smederee.html.LinkTools.*
+import de.smederee.hub.RequestHelpers.instances.given
+import de.smederee.hub.config.*
+import de.smederee.hub.forms.types.*
+import de.smederee.i18n.LanguageCode
+import de.smederee.security.*
+import org.http4s.*
+import org.http4s.dsl.Http4sDsl
+import org.http4s.headers.*
+import org.http4s.implicits.*
+import org.http4s.twirl.TwirlInstances.*
+import org.slf4j.LoggerFactory
+
+/** Routes for handling organisation related functionality.
+  *
+  * @param configuration
+  *   The hub service configuration.
+  * @param orgRepo
+  *   A repository for handling database functionality for organisations.
+  * @tparam F
+  *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
+  */
+final class OrganisationRoutes[F[_]: Async](configuration: ServiceConfig, orgRepo: OrganisationRepository[F])
+    extends Http4sDsl[F] {
+    private val log                               = LoggerFactory.getLogger(getClass)
+    given org.typelevel.log4cats.LoggerFactory[F] = org.typelevel.log4cats.slf4j.Slf4jFactory.create[F]
+
+    private val createOrgPath = uri"/org/create"
+    private val linkConfig    = configuration.external
+
+    /** Load an organisation and related metadata from the database if the permissions allow it.
+      *
+      * @param currentUser
+      *   The user account that is requesting access to the repository or None for a guest user.
+      * @param organisationName
+      *   The name of the organisation.
+      * @return
+      *   An option holding a tuple of the organisation, the list of user accounts that are administrators for it and
+      *   the account of the current organisation owner.
+      */
+    private def loadOrganisation(
+        currentUser: Option[Account]
+    )(organisationName: Username): F[Option[(Organisation, List[Account], Account)]] =
+        for {
+            org    <- orgRepo.findByName(organisationName)
+            owner  <- org.map(_.oid).traverse(orgRepo.findOwner)
+            admins <- org.map(_.oid).traverse(id => orgRepo.getAdministrators(id).compile.toList)
+            orgaAndAdmins = (org, admins, owner.flatten).mapN { case (organisation, administrators, owner) =>
+                (organisation, administrators, owner)
+            }
+            // TODO: Replace with whatever we implement as proper permission model. ;-)
+            result = currentUser match {
+                case None => orgaAndAdmins.filter(_._1.isPrivate === false)
+                case Some(user) =>
+                    orgaAndAdmins.filter(tuple =>
+                        tuple._1.isPrivate === false || tuple._1.owner === user.uid || tuple._2.exists(_ === user)
+                    )
+            }
+        } yield result
+
+    private val createOrganisation: AuthedRoutes[Account, F] = AuthedRoutes.of {
+        case ar @ POST -> Root / "org" / "create" as user =>
+            ar.req.decodeStrict[F, UrlForm] { urlForm =>
+                for {
+                    csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+                    language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+                    _ <- Sync[F].raiseUnless(user.validatedEmail)(
+                        new Error(
+                            "An unvalidated account is not allowed to create an organisation!"
+                        ) // FIXME: Proper error handling!
+                    )
+                    formData <- Sync[F].delay {
+                        urlForm.values.map { t =>
+                            val (key, values) = t
+                            (
+                                key,
+                                values.headOption.getOrElse("")
+                            ) // Pick the first value (a field might get submitted multiple times)!
+                        }
+                    }
+                    form <- Sync[F].delay(OrganisationForm.validate(formData))
+                    // TODO: Cleanup the checking into something like `form.andThen(...)` or so.
+                    orgExists <- form.traverse(validForm => orgRepo.findByName(validForm.name))
+                    checkedForm = orgExists match {
+                        case Validated.Invalid(_) => form
+                        case Validated.Valid(possibleOrg) =>
+                            possibleOrg.fold(form)(_ =>
+                                Map(
+                                    OrganisationForm.fieldName -> List(FormFieldError("Name already exists!"))
+                                ).invalidNec
+                            )
+                    }
+                    resp <- checkedForm match {
+                        case Validated.Invalid(es) =>
+                            BadRequest(
+                                views.html
+                                    .createOrganisation(lang = language)(
+                                        createOrgPath,
+                                        csrf,
+                                        Nil,
+                                        "Smederee - Create a new organisation".some,
+                                        user
+                                    )(
+                                        formData,
+                                        FormErrors.fromNec(es)
+                                    )
+                            )
+                        case Validated.Valid(newOrganisationForm) =>
+                            val organisation = Organisation(
+                                oid = OrganisationId.randomOrganisationId,
+                                name = newOrganisationForm.name,
+                                owner = user.uid,
+                                fullName = newOrganisationForm.fullName,
+                                description = newOrganisationForm.description,
+                                isPrivate = newOrganisationForm.isPrivate,
+                                website = newOrganisationForm.website
+                            )
+                            val targetUri = linkConfig.createFullUri(
+                                Uri(path = Uri.Path(Vector(Uri.Path.Segment(s"~${organisation.name.toString}"))))
+                            )
+                            orgRepo.create(organisation) *> SeeOther(Location(targetUri))
+                    }
+                } yield resp
+            }
+    }
+
+    private val editOrganisation: AuthedRoutes[Account, F] = AuthedRoutes.of {
+        case ar @ POST -> Root / UsernamePathParameter(organisationName) / "edit" as user =>
+            ar.req.decodeStrict[F, UrlForm] { urlForm =>
+                for {
+                    csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+                    language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+                    _ <- Sync[F].raiseUnless(user.validatedEmail)(
+                        new Error(
+                            "An unvalidated account is not allowed to edit an organisation!"
+                        ) // FIXME: Proper error handling!
+                    )
+                    orgAndAdmins <- loadOrganisation(user.some)(organisationName)
+                    orgaData = orgAndAdmins.filter(tuple => tuple._1.owner === user.uid || tuple._2.exists(_ === user))
+                    resp <- orgaData match {
+                        case None => NotFound()
+                        case Some((organisation, admins, owner)) =>
+                            for {
+                                formData <- Sync[F].delay {
+                                    urlForm.values.map { t =>
+                                        val (key, values) = t
+                                        (
+                                            key,
+                                            values.headOption.getOrElse("")
+                                        ) // Pick the first value (a field might get submitted multiple times)!
+                                    }
+                                }
+                                form <- Sync[F].delay(OrganisationForm.validate(formData))
+                                resp <- form match {
+                                    case Validated.Invalid(es) =>
+                                        val editOrgPath = linkConfig.createFullUri(
+                                            Uri(path =
+                                                Uri.Path(
+                                                    Vector(
+                                                        Uri.Path.Segment(s"~${organisation.name.toString}"),
+                                                        Uri.Path.Segment("edit")
+                                                    )
+                                                )
+                                            )
+                                        )
+                                        val possibleOwners = (List(owner, user) ::: admins).distinct
+                                        BadRequest(
+                                            views.html
+                                                .editOrganisation(lang = language)(
+                                                    editOrgPath,
+                                                    csrf,
+                                                    possibleOwners,
+                                                    Option(s"~$organisationName - edit"),
+                                                    user
+                                                )(
+                                                    formData,
+                                                    FormErrors.fromNec(es)
+                                                )
+                                        )
+                                    case Validated.Valid(newOrganisationForm) =>
+                                        val updatedOrganisation = organisation.copy(
+                                            owner = user.uid,
+                                            fullName = newOrganisationForm.fullName,
+                                            description = newOrganisationForm.description,
+                                            isPrivate = newOrganisationForm.isPrivate,
+                                            website = newOrganisationForm.website
+                                        )
+                                        val targetUri = linkConfig.createFullUri(
+                                            Uri(path =
+                                                Uri.Path(Vector(Uri.Path.Segment(s"~${organisation.name.toString}")))
+                                            )
+                                        )
+                                        orgRepo.update(updatedOrganisation) *> SeeOther(Location(targetUri))
+                                }
+                            } yield resp
+                    }
+                } yield resp
+            }
+    }
+
+    private val showCreateOrganisationForm: AuthedRoutes[Account, F] = AuthedRoutes.of {
+        case ar @ GET -> Root / "org" / "create" as user =>
+            for {
+                csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+                language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+                resp <- user.validatedEmail match {
+                    case false =>
+                        Forbidden(
+                            views.html.errors
+                                .unvalidatedAccount(lang = language)(
+                                    csrf,
+                                    "Smederee - Account not validated!".some,
+                                    user
+                                )
+                        )
+                    case true =>
+                        Ok(
+                            views.html
+                                .createOrganisation(lang = language)(
+                                    createOrgPath,
+                                    csrf,
+                                    Nil,
+                                    "Smederee - Create a new organisation".some,
+                                    user
+                                )()
+                        )
+                }
+            } yield resp
+    }
+
+    private val showEditOrganisationForm: AuthedRoutes[Account, F] = AuthedRoutes.of {
+        case ar @ GET -> Root / UsernamePathParameter(organisationName) / "edit" as user =>
+            for {
+                csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+                language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+                orgAndAdmins <- loadOrganisation(user.some)(organisationName)
+                orgaData = orgAndAdmins.filter(tuple => tuple._1.owner === user.uid || tuple._2.exists(_ === user))
+                resp <- orgaData match {
+                    case None => NotFound("Organisation not found!")
+                    case Some((organisation, admins, owner)) =>
+                        user.validatedEmail match {
+                            case false =>
+                                Forbidden(
+                                    views.html.errors
+                                        .unvalidatedAccount(lang = language)(
+                                            csrf,
+                                            "Smederee - Account not validated!".some,
+                                            user
+                                        )
+                                )
+                            case true =>
+                                val editOrgPath = linkConfig.createFullUri(
+                                    Uri(path =
+                                        Uri.Path(
+                                            Vector(
+                                                Uri.Path.Segment(s"~${organisation.name.toString}"),
+                                                Uri.Path.Segment("edit")
+                                            )
+                                        )
+                                    )
+                                )
+                                val possibleOwners = (List(owner, user) ::: admins).distinct
+                                val formData       = OrganisationForm.from(organisation).toMap
+                                Ok(
+                                    views.html
+                                        .editOrganisation(lang = language)(
+                                            editOrgPath,
+                                            csrf,
+                                            possibleOwners,
+                                            Option(s"~$organisationName - edit"),
+                                            user
+                                        )(formData)
+                                )
+                        }
+                }
+            } yield resp
+    }
+
+    val protectedRoutes =
+        showCreateOrganisationForm <+> createOrganisation <+> showEditOrganisationForm <+> editOrganisation
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/Organisation.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/Organisation.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/Organisation.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Organisation.scala	2025-01-13 07:38:10.472647097 +0000
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import java.util.UUID
+
+import cats.*
+import cats.syntax.all.*
+import de.smederee.security.*
+import org.http4s.Uri
+
+import scala.util.matching.Regex
+
+/** A organisation id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath.
+  */
+opaque type OrganisationId = UUID
+object OrganisationId {
+    val Format: Regex = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r
+
+    given Eq[OrganisationId]       = Eq.fromUniversalEquals
+    given Ordering[OrganisationId] = (x: OrganisationId, y: OrganisationId) => x.compareTo(y)
+    given Order[OrganisationId]    = Order.fromOrdering
+
+    /** Create an instance of OrganisationId from the given UUID type.
+      *
+      * @param source
+      *   An instance of type UUID which will be returned as a OrganisationId.
+      * @return
+      *   The appropriate instance of OrganisationId.
+      */
+    def apply(source: UUID): OrganisationId = source
+
+    /** Try to create an instance of OrganisationId from the given UUID.
+      *
+      * @param source
+      *   A UUID that should fulfil the requirements to be converted into a OrganisationId.
+      * @return
+      *   An option to the successfully converted OrganisationId.
+      */
+    def from(source: UUID): Option[OrganisationId] = Option(source)
+
+    /** Try to create an instance of OrganisationId from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a OrganisationId.
+      * @return
+      *   An option to the successfully converted OrganisationId.
+      */
+    def fromString(source: String): Either[String, OrganisationId] =
+        Option(source)
+            .filter(s => Format.matches(s))
+            .flatMap { uuidString =>
+                Either.catchNonFatal(UUID.fromString(uuidString)).toOption
+            }
+            .toRight("Illegal value for OrganisationId!")
+
+    /** Generate a new random organisation id.
+      *
+      * @return
+      *   A organisation id which is pseudo randomly generated.
+      */
+    def randomOrganisationId: OrganisationId = UUID.randomUUID
+
+    extension (uid: OrganisationId) {
+        def toUUID: UUID = uid
+    }
+}
+
+/** Textual description of an [[Organisation]] limited in length to 254 characters.
+  */
+opaque type OrganisationDescription = String
+object OrganisationDescription {
+    val MaximumLength: Int = 254
+
+    /** Create an instance of OrganisationDescription from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a OrganisationDescription.
+      * @return
+      *   The appropriate instance of OrganisationDescription.
+      */
+    def apply(source: String): OrganisationDescription = source
+
+    /** Try to create an instance of OrganisationDescription from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a OrganisationDescription.
+      * @return
+      *   An option to the successfully converted OrganisationDescription.
+      */
+    def from(source: String): Option[OrganisationDescription] = Option(source).map(_.take(MaximumLength))
+
+}
+
+/** An organisation. Because an organisation can own repositories the same restrictions apply to certain attributes of
+  * it like to user accounts. Furthermore an organisation can have multiple owners (upon creation only one) that are
+  * allowed to make changes to it.
+  *
+  * @param oid
+  *   The globally unique ID of the organisation.
+  * @param name
+  *   A unique name which will be used in urls.
+  * @param owner
+  *   The globally unique ID of the user owning the organisation.
+  * @param fullName
+  *   An optional full name for the organisation.
+  * @param description
+  *   An optional description of the organisation.
+  * @param isPrivate
+  *   A flag indicating if this organisation is private i.e. only visible / accessible for accounts with appropriate
+  *   permissions.
+  * @param website
+  *   An optional uri pointing to a website related to the organisation.
+  */
+final case class Organisation(
+    oid: OrganisationId,
+    name: Username,
+    owner: UserId,
+    fullName: Option[FullName],
+    description: Option[OrganisationDescription],
+    isPrivate: Boolean,
+    website: Option[Uri]
+)
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-13 07:38:10.468647086 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-13 07:38:10.472647097 +0000
@@ -56,6 +56,8 @@
   *   A repository for handling database operations regarding our vcs repositories and their metadata.
   * @param ticketsProjectRepo
   *   A repository for handling the synchronisation with the ticket service.
+  * @param orgRepo
+  *   A repository for handling database functionality for organisations.
   * @tparam F
   *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
   */
@@ -63,7 +65,8 @@
     configuration: ServiceConfig,
     darcs: DarcsCommands[F],
     vcsMetadataRepo: VcsMetadataRepository[F],
-    ticketsProjectRepo: ProjectRepository[F]
+    ticketsProjectRepo: ProjectRepository[F],
+    orgRepo: OrganisationRepository[F]
 ) extends Http4sDsl[F] {
     private val log                               = LoggerFactory.getLogger(getClass)
     given org.typelevel.log4cats.LoggerFactory[F] = org.typelevel.log4cats.slf4j.Slf4jFactory.create[F]
@@ -301,15 +304,19 @@
         csrf: Option[CsrfToken]
     )(repositoriesOwnerName: Username)(user: Option[Account]): F[Response[F]] =
         for {
-            owner    <- vcsMetadataRepo.findVcsRepositoryOwner(repositoriesOwnerName)
+            owner <- vcsMetadataRepo.findVcsRepositoryOwner(repositoriesOwnerName)
+            // FIXME: Load with proper permission check!
+            organisation <- orgRepo
+                .findByName(repositoriesOwnerName)
+                .map(_.filter(o => o.isPrivate === false || user.exists(_.uid === o.owner)))
             language <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en")))
             repos    <- owner.traverse(owner => vcsMetadataRepo.listRepositories(user)(owner).compile.toList)
             actionBaseUri <- Sync[F].delay(
                 linkConfig.createFullUri(Uri(path = Uri.Path.unsafeFromString(s"~$repositoriesOwnerName")))
             )
-            resp <- owner match {
-                case None => // TODO: Better error message...
-                    NotFound(
+            resp <- (owner, organisation) match {
+                case (Some(owner), _) =>
+                    Ok(
                         views.html.showRepositories(lang = language)(
                             actionBaseUri,
                             csrf,
@@ -317,11 +324,20 @@
                             user
                         )(repos.getOrElse(List.empty), repositoriesOwnerName)
                     )
-                case Some(_) =>
+                case (None, Some(organisation)) =>
                     Ok(
                         views.html.showRepositories(lang = language)(
                             actionBaseUri,
                             csrf,
+                            s"Smederee/~$repositoriesOwnerName".some,
+                            user
+                        )(repos.getOrElse(List.empty), repositoriesOwnerName)
+                    )
+                case _ => // TODO: Better error message...
+                    NotFound(
+                        views.html.showRepositories(lang = language)(
+                            actionBaseUri,
+                            csrf,
                             s"Smederee/~$repositoriesOwnerName".some,
                             user
                         )(repos.getOrElse(List.empty), repositoriesOwnerName)
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/createOrganisation.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/createOrganisation.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/createOrganisation.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/createOrganisation.scala.html	2025-01-13 07:38:10.476647107 +0000
@@ -0,0 +1,41 @@
+@import de.smederee.hub.*
+@import de.smederee.hub.OrganisationForm.*
+@import de.smederee.hub.forms.types.*
+@import de.smederee.hub.views.html.forms.*
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(action: Uri, csrf: Option[CsrfToken] = None, possibleOwners: List[Account], title: Option[String] = None, user: Account)(formData: Map[String, String] = Map.empty, formErrors: FormErrors = FormErrors.empty)
+@main(baseUri, lang)()(csrf, title, user.some) {
+@defining(lang.toLocale) { implicit locale =>
+  <div class="content">
+    <div class="pure-g">
+      <div class="pure-u-1-1 pure-u-md-1-1">
+        <div class="l-box">
+          <div class="form-errors">
+            @formErrors.get(fieldGlobal).map { es =>
+              @for(error <- es) {
+                <p class="alert alert-error">
+                  <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+                  <span class="sr-only">Fehler:</span>
+                  @error
+                </p>
+              }
+            }
+          </div>
+          <div class="organisation-form">
+            <form action="@action" method="POST" accept-charset="UTF-8" class="pure-form pure-form-aligned">
+              <fieldset id="organisation-data">
+                @organisationFormFields(possibleOwners)(formData, formErrors)
+                @csrfToken(csrf)
+                <div class="pure-controls">
+                  <button type="submit" class="pure-button">@Messages("form.organisation.button.create.submit")</button>
+                </div>
+              </fieldset>
+            </form>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+}
+}
+
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisation.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisation.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisation.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisation.scala.html	2025-01-13 07:38:10.476647107 +0000
@@ -0,0 +1,41 @@
+@import de.smederee.hub.*
+@import de.smederee.hub.OrganisationForm.*
+@import de.smederee.hub.forms.types.*
+@import de.smederee.hub.views.html.forms.*
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(action: Uri, csrf: Option[CsrfToken] = None, possibleOwners: List[Account], title: Option[String] = None, user: Account)(formData: Map[String, String] = Map.empty, formErrors: FormErrors = FormErrors.empty)
+@main(baseUri, lang)()(csrf, title, user.some) {
+@defining(lang.toLocale) { implicit locale =>
+  <div class="content">
+    <div class="pure-g">
+      <div class="pure-u-1-1 pure-u-md-1-1">
+        <div class="l-box">
+          <div class="form-errors">
+            @formErrors.get(fieldGlobal).map { es =>
+              @for(error <- es) {
+                <p class="alert alert-error">
+                  <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+                  <span class="sr-only">Fehler:</span>
+                  @error
+                </p>
+              }
+            }
+          </div>
+          <div class="organisation-form">
+            <form action="@action" method="POST" accept-charset="UTF-8" class="pure-form pure-form-aligned">
+              <fieldset id="organisation-data">
+                @organisationFormFields(possibleOwners)(formData, formErrors)
+                @csrfToken(csrf)
+                <div class="pure-controls">
+                  <button type="submit" class="pure-button">@Messages("form.organisation.button.edit.submit")</button>
+                </div>
+              </fieldset>
+            </form>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+}
+}
+
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/forms/organisationFormFields.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/forms/organisationFormFields.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/forms/organisationFormFields.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/forms/organisationFormFields.scala.html	2025-01-13 07:38:10.476647107 +0000
@@ -0,0 +1,49 @@
+@import de.smederee.hub.*
+@import de.smederee.hub.OrganisationForm.*
+@import de.smederee.hub.forms.types.*
+
+@(possibleOwners: List[Account])(formData: Map[String, String] = Map.empty, formErrors: FormErrors = FormErrors.empty)(implicit locale: java.util.Locale)
+<div class="pure-control-group">
+  <label for="@{fieldName}">@Messages("form.organisation.name")</label>
+  <input class="pure-input-1-2" id="@{fieldName}" name="@{fieldName}" placeholder="@Messages("form.organisation.name.placeholder")" maxlength="31" required="" type="text" value="@{formData.get(fieldName)}" autofocus>
+  <small class="pure-form-message" id="@{fieldName}.help">@Messages("form.organisation.name.help")</small>
+  @renderFormErrors(fieldName, formErrors)
+</div>
+@if(formData.get(fieldOwner).nonEmpty && possibleOwners.nonEmpty) {
+  <div class="pure-control-group">
+    <label for="@{fieldOwner}">@Messages("form.organisation.owner")</label>
+    <select class="pure-input-1-2" id="@fieldOwner" name="@fieldOwner">
+      @for(owner <- possibleOwners) {
+        @defining(owner.fullName.map(fullName => s"($fullName)")) { fullName =>
+        <option value="@owner.uid" @if(formData.get(fieldOwner).exists(_ === owner.uid.toString)){selected}else{}>@owner.name @fullName</option>
+        }
+      }
+    </select>
+    <span class="pure-form-message" id="@{fieldOwner}.help">@Messages("form.organisation.owner.help")</span>
+    @renderFormErrors(fieldOwner, formErrors)
+  </div>
+}else{}
+<div class="pure-control-group">
+  <label for="@{fieldFullName}">@Messages("form.organisation.full-name")</label>
+  <input class="pure-input-1-2" id="@{fieldFullName}" name="@{fieldFullName}" maxlength="128" placeholder="@Messages("form.organisation.full-name.placeholder")" type="text" value="@{formData.get(fieldFullName)}">
+  <span class="pure-form-message" id="@{fieldFullName}.help">@Messages("form.organisation.full-name.help")</span>
+  @renderFormErrors(fieldFullName, formErrors)
+</div>
+<div class="pure-control-group">
+  <label for="@{fieldIsPrivate}">@Messages("form.organisation.is-private")</label>
+  <input id="@{fieldIsPrivate}" name="@{fieldIsPrivate}" type="checkbox" value="true" @if(formData.get(fieldIsPrivate).map(_ === "true").getOrElse(false)){ checked="" } else { }>
+  <span class="pure-form-message-inline" id="@{fieldIsPrivate}.help">@Messages("form.organisation.is-private.help")</span>
+  @renderFormErrors(fieldIsPrivate, formErrors)
+</div>
+<div class="pure-control-group">
+  <label for="@{fieldDescription}">@Messages("form.organisation.description")</label>
+  <textarea class="pure-input-1-2" id="@{fieldDescription}" name="@{fieldDescription}" placeholder="@Messages("form.organisation.description.placeholder")" maxlength="254" rows="3">@{formData.get(fieldDescription)}</textarea>
+  <span class="pure-form-message" id="@{fieldDescription}.help">@Messages("form.organisation.description.help")</span>
+  @renderFormErrors(fieldDescription, formErrors)
+</div>
+<div class="pure-control-group">
+  <label for="@{fieldWebsite}">@Messages("form.organisation.website")</label>
+  <input class="pure-input-1-3" id="@{fieldWebsite}" name="@{fieldWebsite}" maxlength="128" placeholder="https://example.com" type="text" value="@{formData.get(fieldWebsite)}">
+  <span class="pure-form-message" id="@{fieldWebsite}.help">@Messages("form.organisation.website.help")</span>
+  @renderFormErrors(fieldWebsite, formErrors)
+</div>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html	2025-01-13 07:38:10.472647097 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html	2025-01-13 07:38:10.476647107 +0000
@@ -12,6 +12,7 @@
         <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("org/create")}">+ @Messages("global.navbar.top.organisation.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">
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html	2025-01-13 07:38:10.472647097 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html	2025-01-13 07:38:10.476647107 +0000
@@ -13,7 +13,7 @@
       </div>
     </div>
     <div class="pure-g">
-      <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="pure-u-4-5 pure-u-md-4-5">
         <div class="l-box">
         @if(listing.nonEmpty) {
         <table class="pure-table pure-table-horizontal">
@@ -39,6 +39,8 @@
         }
         </div>
       </div>
+      <div class="pure-u-1-5 pure-u-md-1-5">
+      </div>
     </div>
   </div>
 }
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieOrganisationRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieOrganisationRepositoryTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieOrganisationRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieOrganisationRepositoryTest.scala	2025-01-13 07:38:10.476647107 +0000
@@ -0,0 +1,319 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import cats.effect.*
+import cats.syntax.all.*
+import de.smederee.TestTags.*
+import de.smederee.hub.Generators.*
+import de.smederee.security.*
+import doobie.*
+
+final class DoobieOrganisationRepositoryTest extends BaseSpec {
+    test("addAdministrator must add an administrator to the organisation".tag(NeedsDatabase)) {
+        (genValidAccounts.sample, genOrganisation.sample) match {
+            case (Some(owner :: accounts), Some(org)) =>
+                val organisation = org.copy(owner = owner.uid)
+                val dbConfig     = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val repo = new DoobieOrganisationRepository[IO](tx)
+                val test = for {
+                    _ <- createAccount(owner, PasswordHash("I am not a password hash!"), None, None)
+                    _ <- accounts.traverse(a => createAccount(a, PasswordHash("I am not a password hash!"), None, None))
+                    written <- repo.create(organisation)
+                    added   <- accounts.headOption.traverse(admin => repo.addAdministrator(organisation.oid)(admin.uid))
+                } yield (written, added.getOrElse(0))
+                test.map { result =>
+                    val (written, added) = result
+                    assert(written === 1, "Organisation not written to database!")
+                    accounts.headOption.foreach(_ => assert(added === 1, "Administrator not written to database!"))
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("create must create an organisation".tag(NeedsDatabase)) {
+        (genValidAccount.sample, genOrganisation.sample) match {
+            case (Some(account), Some(org)) =>
+                val organisation = org.copy(owner = account.uid)
+                val dbConfig     = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val repo = new DoobieOrganisationRepository[IO](tx)
+                val test = for {
+                    _       <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+                    written <- repo.create(organisation)
+                } yield written
+                test.map { written =>
+                    assert(written === 1, "Creating an organisation must modify one database row!")
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("allByOwner must return all organisations owned by the user".tag(NeedsDatabase)) {
+        (genValidAccount.sample, genOrganisations.sample) match {
+            case (Some(account), Some(orgs)) =>
+                val organisations = orgs.map(_.copy(owner = account.uid))
+                val dbConfig      = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val repo = new DoobieOrganisationRepository[IO](tx)
+                val test = for {
+                    _       <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+                    written <- organisations.traverse(repo.create)
+                    found   <- repo.allByOwner(account.uid).compile.toList
+                } yield (written, found)
+                test.map { result =>
+                    val (written, found) = result
+                    assert(written.sum === organisations.size, "Not all organisations written to database!")
+                    assertEquals(found.size, organisations.size, "Not all organisations found!")
+                    assertEquals(found.sortBy(_.oid), organisations.sortBy(_.oid))
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("delete must delete the organisation".tag(NeedsDatabase)) {
+        (genValidAccount.sample, genOrganisation.sample) match {
+            case (Some(account), Some(org)) =>
+                val organisation = org.copy(owner = account.uid)
+                val dbConfig     = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val repo = new DoobieOrganisationRepository[IO](tx)
+                val test = for {
+                    _       <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+                    written <- repo.create(organisation)
+                    deleted <- repo.delete(organisation.oid)
+                    found   <- repo.findByName(organisation.name)
+                } yield (written, deleted, found)
+                test.map { result =>
+                    val (written, deleted, found) = result
+                    assert(written === 1, "Creating an organisation must modify one database row!")
+                    assert(deleted === 1, "Deleting an organisation must modify one database row!")
+                    assertEquals(found, None, "Organisation was not deleted from database!")
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("find must return the organisation with the given id".tag(NeedsDatabase)) {
+        (genValidAccount.sample, genOrganisation.sample) match {
+            case (Some(account), Some(org)) =>
+                val organisation = org.copy(owner = account.uid)
+                val dbConfig     = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val repo = new DoobieOrganisationRepository[IO](tx)
+                val test = for {
+                    _       <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+                    written <- repo.create(organisation)
+                    found   <- repo.find(organisation.oid)
+                } yield (written, found)
+                test.map { result =>
+                    val (written, found) = result
+                    assert(written === 1, "Creating an organisation must modify one database row!")
+                    assertEquals(found, organisation.some)
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("findByName must return a matching organisation".tag(NeedsDatabase)) {
+        (genValidAccount.sample, genOrganisation.sample) match {
+            case (Some(account), Some(org)) =>
+                val organisation = org.copy(owner = account.uid)
+                val dbConfig     = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val repo = new DoobieOrganisationRepository[IO](tx)
+                val test = for {
+                    _       <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+                    written <- repo.create(organisation)
+                    found   <- repo.findByName(organisation.name)
+                } yield (written, found)
+                test.map { result =>
+                    val (written, found) = result
+                    assert(written === 1, "Creating an organisation must modify one database row!")
+                    assertEquals(found, organisation.some)
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("findOwner must return the user account of the owner".tag(NeedsDatabase)) {
+        (genValidAccount.sample, genOrganisation.sample) match {
+            case (Some(account), Some(org)) =>
+                val organisation = org.copy(owner = account.uid)
+                val dbConfig     = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val repo = new DoobieOrganisationRepository[IO](tx)
+                val test = for {
+                    _       <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+                    written <- repo.create(organisation)
+                    found   <- repo.findOwner(organisation.oid)
+                } yield (written, found)
+                test.map { result =>
+                    val (written, found) = result
+                    assert(written === 1, "Creating an organisation must modify one database row!")
+                    assertEquals(found, account.copy(language = None).some)
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("getAdministrators must return all administrators".tag(NeedsDatabase)) {
+        (genValidAccounts.sample, genOrganisation.sample) match {
+            case (Some(owner :: accounts), Some(org)) =>
+                val admins = accounts.zipWithLongIndex.foldLeft(List.empty[Account])((acc, tuple) =>
+                    if (tuple._2 % 2 === 0) {
+                        tuple._1 :: acc
+                    } else {
+                        acc
+                    }
+                )
+                val organisation = org.copy(owner = owner.uid)
+                val dbConfig     = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val repo     = new DoobieOrganisationRepository[IO](tx)
+                val addAdmin = repo.addAdministrator(organisation.oid)
+                val test = for {
+                    _ <- createAccount(owner, PasswordHash("I am not a password hash!"), None, None)
+                    _ <- accounts.traverse(a => createAccount(a, PasswordHash("I am not a password hash!"), None, None))
+                    written     <- repo.create(organisation)
+                    added       <- admins.map(_.uid).traverse(addAdmin)
+                    foundAdmins <- repo.getAdministrators(organisation.oid).compile.toList
+                } yield (written, added.sum, foundAdmins)
+                test.map { result =>
+                    val (written, added, foundAdmins) = result
+                    assert(written === 1, "Organisation not written to database!")
+                    assert(added === admins.size, "Wrong number of administrators created!")
+                    assertEquals(foundAdmins.map(_.uid).sorted, admins.map(_.uid).sorted)
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("removeAdministrator must remove an administrator from an organisation".tag(NeedsDatabase)) {
+        (genValidAccounts.sample, genOrganisation.sample) match {
+            case (Some(owner :: accounts), Some(org)) =>
+                val organisation = org.copy(owner = owner.uid)
+                val dbConfig     = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val repo = new DoobieOrganisationRepository[IO](tx)
+                val test = for {
+                    _ <- createAccount(owner, PasswordHash("I am not a password hash!"), None, None)
+                    _ <- accounts.traverse(a => createAccount(a, PasswordHash("I am not a password hash!"), None, None))
+                    written <- repo.create(organisation)
+                    added   <- accounts.headOption.traverse(admin => repo.addAdministrator(organisation.oid)(admin.uid))
+                    removed <- accounts.headOption.traverse(admin =>
+                        repo.removeAdministrator(organisation.oid)(admin.uid)
+                    )
+                } yield (written, added.getOrElse(0), removed.getOrElse(0))
+                test.map { result =>
+                    val (written, added, removed) = result
+                    assert(written === 1, "Organisation not written to database!")
+                    accounts.headOption.foreach { _ =>
+                        assert(added === 1, "Administrator not written to database!")
+                        assert(removed === 1, "Administrator not removed from database!")
+                    }
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("update must update the organisation data".tag(NeedsDatabase)) {
+        (genValidAccount.sample, genOrganisation.sample, genOrganisation.sample) match {
+            case (Some(account), Some(org), Some(anotherOrg)) =>
+                val organisation        = org.copy(owner = account.uid)
+                val updatedOrganisation = anotherOrg.copy(oid = organisation.oid, owner = organisation.owner)
+                val dbConfig            = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val repo = new DoobieOrganisationRepository[IO](tx)
+                val test = for {
+                    _       <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+                    written <- repo.create(organisation)
+                    updated <- repo.update(updatedOrganisation)
+                    found   <- repo.find(organisation.oid)
+                } yield (written, updated, found)
+                test.map { result =>
+                    val (written, updated, found) = result
+                    assert(written === 1, "Creating an organisation must modify one database row!")
+                    assert(updated === 1, "Updating an organisation must modify one database row!")
+                    assertEquals(found, updatedOrganisation.some)
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+}
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-13 07:38:10.472647097 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-01-13 07:38:10.476647107 +0000
@@ -140,6 +140,28 @@
                 a :: acc
         }) // Ensure distinct user names.
 
+    val genOrganisationId: Gen[OrganisationId] = Gen.delay(OrganisationId.randomOrganisationId)
+
+    val genOrganisation: Gen[Organisation] =
+        for {
+            oid         <- genOrganisationId
+            name        <- genValidUsername
+            owner       <- genUserId
+            fullName    <- Gen.option(genValidFullName)
+            description <- Gen.alphaNumStr.map(OrganisationDescription.from)
+            isPrivate   <- Gen.oneOf(List(false, true))
+        } yield Organisation(
+            oid = oid,
+            name = name,
+            owner = owner,
+            fullName = fullName,
+            description = description,
+            isPrivate = isPrivate,
+            website = None
+        )
+
+    val genOrganisations: Gen[List[Organisation]] = Gen.nonEmptyListOf(genOrganisation)
+
     val genValidSession: Gen[Session] =
         for {
             id  <- genSessionId
diff -rN -u old-smederee/modules/security/src/main/scala/de/smederee/security/UserId.scala new-smederee/modules/security/src/main/scala/de/smederee/security/UserId.scala
--- old-smederee/modules/security/src/main/scala/de/smederee/security/UserId.scala	2025-01-13 07:38:10.472647097 +0000
+++ new-smederee/modules/security/src/main/scala/de/smederee/security/UserId.scala	2025-01-13 07:38:10.476647107 +0000
@@ -30,7 +30,9 @@
 object UserId {
     val Format: Regex = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r
 
-    given Eq[UserId] = Eq.fromUniversalEquals
+    given Eq[UserId]       = Eq.fromUniversalEquals
+    given Ordering[UserId] = (x: UserId, y: UserId) => x.compareTo(y)
+    given Order[UserId]    = Order.fromOrdering
 
     /** Create an instance of UserId from the given UUID type.
       *