~jan0sch/smederee

Showing details for patch abea6b3972b0d8259ce7a0d3b27467bc9f0b1672.
2024-07-03 (Wed), 11:23 AM - Jens Grassel - abea6b3972b0d8259ce7a0d3b27467bc9f0b1672

Hub: Organisation membership handling

- add database table `organisation_members`
- extend `OrganisationRepository` by functions to handle membership
- implement functions in `DoobieOrganisationRepository`
- extend tests and an issue where testing would only test the first entry of a
  list of cases
- add typed form for handling membership operations
- add view to edit organisation membership
- add link and button visible to organisation owner and admins for editing
  members of an organisation
Summary of changes
3 files added
  • modules/hub/src/main/resources/db/migration/hub/V8__organisation_members.sql
  • modules/hub/src/main/scala/de/smederee/hub/OrganisationMembersForm.scala
  • modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisationMembers.scala.html
7 files modified with 354 lines added and 23 lines removed
  • modules/hub/src/main/resources/messages.properties with 7 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieOrganisationRepository.scala with 38 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/OrganisationRepository.scala with 43 added and 3 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala with 125 added and 3 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/account/settingsOrganisations.scala.html with 1 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html with 2 added and 1 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/DoobieOrganisationRepositoryTest.scala with 138 added and 15 removed lines
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/hub/V8__organisation_members.sql new-smederee/modules/hub/src/main/resources/db/migration/hub/V8__organisation_members.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/hub/V8__organisation_members.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/hub/V8__organisation_members.sql	2025-01-11 09:14:48.235985084 +0000
@@ -0,0 +1,17 @@
+CREATE TABLE hub.organisation_members
+(
+  organisation UUID NOT NULL,
+  member       UUID NOT NULL,
+  CONSTRAINT organisation_members_pk     PRIMARY KEY (organisation, member),
+  CONSTRAINT organisation_members_fk_oid FOREIGN KEY (organisation)
+    REFERENCES hub.organisations (id) ON UPDATE CASCADE ON DELETE CASCADE,
+  CONSTRAINT organisation_members_fk_uid FOREIGN KEY (member)
+    REFERENCES hub.accounts (uid) ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE hub.organisation_members IS 'A mapping table for organisation memberships.';
+COMMENT ON COLUMN hub.organisation_members.organisation IS 'The globally unique ID of the organisation.';
+COMMENT ON COLUMN hub.organisation_members.member IS 'The unique ID of a user account that is a member of 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-11 09:14:48.231985083 +0000
+++ new-smederee/modules/hub/src/main/resources/messages.properties	2025-01-11 09:14:48.235985084 +0000
@@ -63,6 +63,11 @@
 form.organisation.name.placeholder=Please enter a organisation name.
 form.organisation.name=Name
 form.organisation.owner.help=You may change the primary owner of the organisation here.
+form.organisation.members.add.name=Add member
+form.organisation.members.add.name.help=You may add a new member to this organisation by specifying the name of a validated user account.
+form.organisation.members.add.name.placeholder=
+form.organisation.members.button.edit.submit=Save changes
+form.organisation.members.delete.notice=Please select all members that you would like to remove.
 form.organisation.owner=Owner
 form.organisation.website.help=An optional URI pointing to a website.
 form.organisation.website.placeholder=https://example.com
@@ -293,6 +298,7 @@
 user.settings.language.title=You preferred language.
 user.settings.organisation.admins=Administrators
 user.settings.organisation.edit=Edit
+user.settings.organisation.members=Members
 user.settings.organisations.description=Here you find all organisations that you''re the owner of.
 user.settings.organisations.title=Organisations
 user.settings.ssh.add.title=Add a new public ssh key.
@@ -365,6 +371,7 @@
 milestone.due-date=Due date
 
 organisation.menu.edit.admins=Manage organisation administrators
+organisation.menu.edit.members=Manage organisation members
 organisation.menu.edit.settings=Edit organisation settings
 
 project.label.edit.link=Edit
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	2025-01-11 09:14:48.231985083 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieOrganisationRepository.scala	2025-01-11 09:14:48.235985084 +0000
@@ -56,6 +56,23 @@
               ) ON CONFLICT (organisation, admin) DO NOTHING""".update.run
             .transact(tx)
 
+    override def addMember(organisationId: OrganisationId)(user: UserId): F[Int] =
+        sql"""INSERT INTO hub.organisation_members (
+                organisation,
+                member
+              ) VALUES (
+                $organisationId,
+                $user
+              ) ON CONFLICT (organisation, member) DO NOTHING""".update.run
+            .transact(tx)
+
+    override def allByMember(member: UserId): Stream[F, Organisation] = {
+        val membersJoin  = fr"""JOIN hub.organisation_members AS members ON organisations.id = members.organisation"""
+        val memberFilter = fr"""members.member = $member"""
+        val sqlQuery     = selectOrganisationColumns ++ membersJoin ++ whereAnd(memberFilter)
+        sqlQuery.query[Organisation].stream.transact(tx)
+    }
+
     override def allByOwner(owner: UserId): Stream[F, Organisation] = {
         val ownerFilter = fr"""owner = $owner"""
         val sqlQuery    = selectOrganisationColumns ++ whereAnd(ownerFilter)
@@ -100,7 +117,7 @@
         sqlQuery.query[Organisation].option.transact(tx)
     }
 
-    override def findNewAdminByName(name: Username): F[Option[Account]] = {
+    override def findAccountByName(name: Username): F[Option[Account]] = {
         val nameFilter           = fr"""name = $name"""
         val notLockedFilter      = fr"""locked_at IS NULL"""
         val validatedEmailFilter = fr"""validated_email IS TRUE"""
@@ -148,10 +165,30 @@
         sqlQuery.query[Account].stream.transact(tx)
     }
 
+    override def getMembers(organisationId: OrganisationId): Stream[F, Account] = {
+        val organisationFilter = fr"""members.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_members AS members
+                 ON accounts.uid = members.member""" ++ 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 removeMember(organisationId: OrganisationId)(user: UserId): F[Int] =
+        sql"""DELETE FROM hub.organisation_members WHERE organisation = $organisationId AND member = $user""".update.run
+            .transact(tx)
+
     override def update(org: Organisation): F[Int] =
         sql"""UPDATE hub.organisations SET
                 name = ${org.name},
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationMembersForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationMembersForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationMembersForm.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationMembersForm.scala	2025-01-11 09:14:48.235985084 +0000
@@ -0,0 +1,56 @@
+/*
+ * 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.*
+
+final case class OrganisationMembersForm(
+    membersToDelete: List[Username],
+    newMemberName: Option[Username]
+)
+
+object OrganisationMembersForm extends FormValidator[OrganisationMembersForm] {
+    val fieldMembersToDelete: FormField = FormField("membersToDelete")
+    val fieldNewMemberName: FormField   = FormField("newMemberName")
+
+    override def validate(data: Map[String, Chain[String]]): ValidatedNec[FormErrors, OrganisationMembersForm] = {
+        val newMemberName = data
+            .get(fieldNewMemberName)
+            .filter(_.nonEmpty)
+            .fold(None.validNec)(
+                _.headOption
+                    .filter(_.nonEmpty)
+                    .fold(None.validNec)(string =>
+                        Username.from(string).fold(FormFieldError("Invalid name!").invalidNec)(_.some.validNec)
+                    )
+            )
+            .leftMap(es => NonEmptyChain.of(Map(fieldNewMemberName -> es.toList)))
+        // TODO: Currently we silently drop invalid names from the list, should we send feedback to the user?
+        val membersToDelete = data
+            .get(fieldMembersToDelete)
+            .filter(_.nonEmpty)
+            .fold(Nil.validNec)(_.toList.map(Username.from).flatten.validNec)
+        (membersToDelete, newMemberName).mapN { case (members, name) =>
+            OrganisationMembersForm(membersToDelete = members, newMemberName = name)
+        }
+    }
+}
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	2025-01-11 09:14:48.231985083 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRepository.scala	2025-01-11 09:14:48.235985084 +0000
@@ -39,6 +39,26 @@
       */
     def addAdministrator(organisationId: OrganisationId)(user: UserId): F[Int]
 
+    /** Add a member to the organisation.
+      *
+      * @param organisationId
+      *   The unique ID of the organisation.
+      * @param user
+      *   The globally unique ID of the user to be added to the organisation.
+      * @return
+      *   The number of affected database rows.
+      */
+    def addMember(organisationId: OrganisationId)(user: UserId): F[Int]
+
+    /** Find all organisations of which the user with the given id is a member of.
+      *
+      * @param owner
+      *   The unique user id of an account.
+      * @return
+      *   A stream with all organisation of which the user is a member of.
+      */
+    def allByMember(member: UserId): Stream[F, Organisation]
+
     /** Find all organisations of which the user with the given id is an owner.
       *
       * @param owner
@@ -84,15 +104,15 @@
       */
     def findByName(name: Username): F[Option[Organisation]]
 
-    /** Find the user account that is supposed to be an new administrator for an organisation by the user name. The user
-      * account must not be locked and must also have validated their email address to be returned.
+    /** Find the user account that is supposed to be a new member or administrator for an organisation by the user name.
+      * The user account must not be locked and must also have validated their email address to be returned.
       *
       * @param name
       *   A user name.
       * @return
       *   An option to the found user account.
       */
-    def findNewAdminByName(name: Username): F[Option[Account]]
+    def findAccountByName(name: Username): F[Option[Account]]
 
     /** Find the account of an organisation owner.
       *
@@ -112,6 +132,15 @@
       */
     def getAdministrators(organisationId: OrganisationId): Stream[F, Account]
 
+    /** Return the accounts of all users that are members of the given organisation.
+      *
+      * @param organisationId
+      *   The unique ID of the organisation.
+      * @return
+      *   A stream of accounts that may be empty.
+      */
+    def getMembers(organisationId: OrganisationId): Stream[F, Account]
+
     /** Remove an administrator from the organisation.
       *
       * @param organisationId
@@ -123,6 +152,17 @@
       */
     def removeAdministrator(organisationId: OrganisationId)(user: UserId): F[Int]
 
+    /** Remove a member 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 removeMember(organisationId: OrganisationId)(user: UserId): F[Int]
+
     /** Update the database entry for the given organisation.
       *
       * @param org
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	2025-01-11 09:14:48.231985083 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala	2025-01-11 09:14:48.235985084 +0000
@@ -299,10 +299,10 @@
                                         val logic = for {
                                             newAdmin <- organisationAdminsForm.newAdminName match {
                                                 case None       => None.pure
-                                                case Some(name) => orgRepo.findNewAdminByName(name)
+                                                case Some(name) => orgRepo.findAccountByName(name)
                                             }
                                             foundAdminsToRemove <- organisationAdminsForm.adminsToDelete.traverse(
-                                                orgRepo.findNewAdminByName
+                                                orgRepo.findAccountByName
                                             )
                                             adminsToRemove = foundAdminsToRemove.flatten
                                             addedAdmin <- newAdmin
@@ -327,6 +327,83 @@
             }
     }
 
+    /** Handle the submission of the form to edit organisation members.
+      */
+    private val editOrganisationMembers: AuthedRoutes[Account, F] = AuthedRoutes.of {
+        case ar @ POST -> Root / "user" / "settings" / "organisations" / UsernamePathParameter(
+                organisationName
+            ) / "members" 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)
+                    orgMembers   <- orgAndAdmins.traverse(tuple => orgRepo.getMembers(tuple._1.oid).compile.toList)
+                    orgaData = orgAndAdmins.filter(tuple => tuple._1.owner === user.uid || tuple._2.exists(_ === user))
+                    resp <- orgaData match {
+                        case None => NotFound()
+                        case Some((organisation, admins, _)) =>
+                            val members = orgMembers.getOrElse(Nil)
+                            for {
+                                formData <- Sync[F].delay(urlForm.valuesWithoutEmptyStrings)
+                                form     <- Sync[F].delay(OrganisationMembersForm.validate(formData))
+                                resp <- form match {
+                                    case Validated.Invalid(es) =>
+                                        val actionBaseUri =
+                                            uri"user/settings/organisations".addSegment(
+                                                s"~${organisation.name.toString}"
+                                            )
+                                        val editAction = linkConfig.createFullUri(actionBaseUri.addSegment("members"))
+                                        BadRequest(
+                                            views.html
+                                                .editOrganisationMembers(lang = language)(
+                                                    editAction,
+                                                    csrf,
+                                                    members,
+                                                    Option(s"~$organisationName - members"),
+                                                    user
+                                                )(
+                                                    formData.withDefaultValue(Chain.empty),
+                                                    FormErrors.fromNec(es)
+                                                )
+                                        )
+                                    case Validated.Valid(organisationMembersForm) =>
+                                        val logic = for {
+                                            newMember <- organisationMembersForm.newMemberName match {
+                                                case None       => None.pure
+                                                case Some(name) => orgRepo.findAccountByName(name)
+                                            }
+                                            foundMembersToRemove <- organisationMembersForm.membersToDelete.traverse(
+                                                orgRepo.findAccountByName
+                                            )
+                                            membersToRemove = foundMembersToRemove.flatten
+                                            addedMember <- newMember
+                                                .map(_.uid)
+                                                .traverse(orgRepo.addMember(organisation.oid))
+                                            removedMembers <- membersToRemove
+                                                .map(_.uid)
+                                                .traverse(orgRepo.removeMember(organisation.oid))
+                                        } yield (addedMember.getOrElse(0), removedMembers.sum)
+                                        val targetUri = linkConfig.createFullUri(
+                                            Uri(path =
+                                                Uri.Path(
+                                                    Vector(Uri.Path.Segment(s"~${organisation.name.toString}"))
+                                                )
+                                            )
+                                        )
+                                        logic *> SeeOther(Location(targetUri))
+                                }
+                            } yield resp
+                    }
+                } yield resp
+            }
+    }
+
     private val editOrganisation: AuthedRoutes[Account, F] = AuthedRoutes.of {
         case ar @ POST -> Root / "user" / "settings" / "organisations" / UsernamePathParameter(
                 organisationName
@@ -472,6 +549,50 @@
             } yield resp
     }
 
+    private val showEditOrganisationMembersForm: AuthedRoutes[Account, F] = AuthedRoutes.of {
+        case ar @ GET -> Root / "user" / "settings" / "organisations" / UsernamePathParameter(
+                organisationName
+            ) / "members" as user =>
+            for {
+                csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+                language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+                orgAndAdmins <- loadOrganisation(user.some)(organisationName)
+                orgMembers   <- orgAndAdmins.traverse(tuple => orgRepo.getMembers(tuple._1.oid).compile.toList)
+                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, _)) =>
+                        val members = orgMembers.getOrElse(Nil)
+                        user.validatedEmail match {
+                            case false =>
+                                Forbidden(
+                                    views.html.errors
+                                        .unvalidatedAccount(lang = language)(
+                                            csrf,
+                                            "Smederee - Account not validated!".some,
+                                            user
+                                        )
+                                )
+                            case true =>
+                                val actionBaseUri =
+                                    uri"user/settings/organisations".addSegment(s"~${organisation.name.toString}")
+                                val editAction =
+                                    linkConfig.createFullUri(actionBaseUri.addSegment("members"))
+                                Ok(
+                                    views.html
+                                        .editOrganisationMembers(lang = language)(
+                                            editAction,
+                                            csrf,
+                                            members,
+                                            Option(s"~$organisationName - members"),
+                                            user
+                                        )()
+                                )
+                        }
+                }
+            } yield resp
+    }
+
     private val showEditOrganisationForm: AuthedRoutes[Account, F] = AuthedRoutes.of {
         case ar @ GET -> Root / "user" / "settings" / "organisations" / UsernamePathParameter(
                 organisationName
@@ -522,7 +643,8 @@
     }
 
     val protectedRoutes =
-        showCreateOrganisationForm <+> createOrganisation <+> showEditOrganisationAdminsForm <+> showEditOrganisationForm <+> deleteOrganisation <+> editOrganisationAdmins <+> editOrganisation
+        showCreateOrganisationForm <+> createOrganisation <+> showEditOrganisationAdminsForm <+> showEditOrganisationMembersForm
+            <+> showEditOrganisationForm <+> deleteOrganisation <+> editOrganisationAdmins <+> editOrganisationMembers <+> editOrganisation
 
     val routes = HttpRoutes.empty
 }
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settingsOrganisations.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settingsOrganisations.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settingsOrganisations.scala.html	2025-01-11 09:14:48.235985084 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settingsOrganisations.scala.html	2025-01-11 09:14:48.235985084 +0000
@@ -42,6 +42,7 @@
             <div class="pure-u-7-12" style="padding: 10px 5px 10px 5px;"><a href="@baseUri.addSegment(s"~$organisationName")">@organisationName</a></div>
             <div class="pure-u-2-12" style="padding: 10px 5px 10px 5px;"><a class="pure-button" href="@organisationActionBaseUri.addSegment(s"~$organisationName").addSegment("edit")">@Messages("user.settings.organisation.edit")</a></div>
             <div class="pure-u-3-12" style="padding: 10px 5px 10px 5px;"><a class="pure-button" href="@organisationActionBaseUri.addSegment(s"~$organisationName").addSegment("admins")">@Messages("user.settings.organisation.admins")</a></div>
+            <div class="pure-u-3-12" style="padding: 10px 5px 10px 5px;"><a class="pure-button" href="@organisationActionBaseUri.addSegment(s"~$organisationName").addSegment("members")">@Messages("user.settings.organisation.members")</a></div>
           </div>
           }
         }
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisationMembers.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisationMembers.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisationMembers.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisationMembers.scala.html	2025-01-11 09:14:48.235985084 +0000
@@ -0,0 +1,65 @@
+@import de.smederee.hub.*
+@import de.smederee.hub.OrganisationMembersForm.*
+@import de.smederee.hub.forms.types.*
+@import de.smederee.hub.views.html.forms.*
+
+@(
+  baseUri: Uri = Uri(path = Uri.Path.Root),
+  lang: LanguageCode = LanguageCode("en")
+)(
+  editAction: Uri,
+  csrf: Option[CsrfToken] = None,
+  members: List[Account],
+  title: Option[String] = None,
+  user: Account
+)(
+  formData: Map[String, Chain[String]] = Map.empty.withDefaultValue(Chain.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="@editAction" method="POST" accept-charset="UTF-8" class="pure-form pure-form-aligned">
+              <fieldset id="organisation-add-member">
+                <div class="pure-control-group">
+                  <label for="@fieldNewMemberName">@Messages("form.organisation.members.add.name")</label>
+                  <input class="pure-input-1-2" id="@fieldNewMemberName" name="@fieldNewMemberName" placeholder="@Messages("form.organisation.members.add.name.placeholder")" maxlength="31" type="text" value="@{formData(fieldNewMemberName).headOption}" autofocus>
+                  <small class="pure-form-message" id="@{fieldNewMemberName}.help">@Messages("form.organisation.members.add.name.help")</small>
+                  @renderFormErrors(fieldNewMemberName, formErrors)
+                </div>
+              </fieldset>
+              <fieldset id="organisation-members">
+                <p class="alert alert-warning">@Messages("form.organisation.members.delete.notice")</p>
+                @for(member <- members) {
+                <label for="remove-member-@{member.name}" class="pure-checkbox">
+                  <input type="checkbox" id="remove-member-@{member.name}" name="@fieldMembersToDelete" value="@{member.name}" /> @{member.name} @{member.fullName.map(fn => "(" + fn + ")")}
+                </label>
+                }
+              </fieldset>
+              @csrfToken(csrf)
+              <div class="pure-controls">
+                <button type="submit" class="pure-button">@Messages("form.organisation.members.button.edit.submit")</button>
+              </div>
+            </form>
+          </div>
+        </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-01-11 09:14:48.235985084 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html	2025-01-11 09:14:48.235985084 +0000
@@ -64,7 +64,8 @@
           @for(orgActionBaseUri <- organisationActionBaseUri) {
           <div class="l-box">
             <a href="@{orgActionBaseUri.addSegment("edit")}">@Messages("organisation.menu.edit.settings")</a><br/>
-            <a href="@{orgActionBaseUri.addSegment("admins")}">@Messages("organisation.menu.edit.admins")</a>
+            <a href="@{orgActionBaseUri.addSegment("admins")}">@Messages("organisation.menu.edit.admins")</a><br/>
+            <a href="@{orgActionBaseUri.addSegment("members")}">@Messages("organisation.menu.edit.members")</a>
           </div>
           }
         } else {
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	2025-01-11 09:14:48.235985084 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieOrganisationRepositoryTest.scala	2025-01-11 09:14:48.235985084 +0000
@@ -53,6 +53,34 @@
         }
     }
 
+    test("addMember must add a member 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(member => repo.addMember(organisation.oid)(member.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, "Member 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)) =>
@@ -77,6 +105,37 @@
         }
     }
 
+    test("allByMember must return all organisations which the user is a member of".tag(NeedsDatabase)) {
+        (genValidAccount.sample, genValidAccount.sample, genOrganisations.sample) match {
+            case (Some(owner), Some(member), Some(orgs)) =>
+                val organisations = orgs.map(_.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)
+                    _                  <- createAccount(member, PasswordHash("I am not a password hash!"), None, None)
+                    writtenOrgs        <- organisations.traverse(repo.create)
+                    writtenMemberships <- organisations.traverse(org => repo.addMember(org.oid)(member.uid))
+                    found              <- repo.allByMember(member.uid).compile.toList
+                } yield (writtenOrgs, writtenMemberships, found)
+                test.map { result =>
+                    val (writtenOrgs, writtenMemberships, found) = result
+                    assert(writtenOrgs.sum === organisations.size, "Not all organisations written to database!")
+                    assert(writtenMemberships.sum === organisations.size, "Not all memberships 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("allByOwner must return all organisations owned by the user".tag(NeedsDatabase)) {
         (genValidAccount.sample, genOrganisations.sample) match {
             case (Some(account), Some(orgs)) =>
@@ -188,7 +247,7 @@
         }
     }
 
-    test("findNewAdminByName must return an unlocked and validated account".tag(NeedsDatabase)) {
+    test("findAccountByName must return an unlocked and validated account".tag(NeedsDatabase)) {
         genValidAccount.sample match {
             case Some(genAccount) =>
                 val account  = genAccount.copy(language = None, validatedEmail = true)
@@ -203,7 +262,7 @@
                 val repo = new DoobieOrganisationRepository[IO](tx)
                 val test = for {
                     _     <- createAccount(account, PasswordHash("I am not a password hash!"))
-                    found <- repo.findNewAdminByName(account.name)
+                    found <- repo.findAccountByName(account.name)
                 } yield found
                 test.map { found =>
                     assertEquals(found, account.some)
@@ -212,7 +271,7 @@
         }
     }
 
-    test("findNewAdminByName must not return a locked account".tag(NeedsDatabase)) {
+    test("findAccountByName must not return a locked account".tag(NeedsDatabase)) {
         genValidAccount.sample match {
             case Some(genAccount) =>
                 val account  = genAccount.copy(validatedEmail = true)
@@ -228,7 +287,7 @@
                 val repo = new DoobieOrganisationRepository[IO](tx)
                 val test = for {
                     _     <- createAccount(account, PasswordHash("I am not a password hash!"), unlockToken = token.some)
-                    found <- repo.findNewAdminByName(account.name)
+                    found <- repo.findAccountByName(account.name)
                 } yield found
                 test.map { found =>
                     assertEquals(found, None)
@@ -237,7 +296,7 @@
         }
     }
 
-    test("findNewAdminByName must not return an unvalidated account".tag(NeedsDatabase)) {
+    test("findAccountByName must not return an unvalidated account".tag(NeedsDatabase)) {
         genValidAccount.sample match {
             case Some(genAccount) =>
                 val account  = genAccount.copy(validatedEmail = false)
@@ -253,7 +312,7 @@
                 val repo = new DoobieOrganisationRepository[IO](tx)
                 val test = for {
                     _ <- createAccount(account, PasswordHash("I am not a password hash!"), validationToken = token.some)
-                    found <- repo.findNewAdminByName(account.name)
+                    found <- repo.findAccountByName(account.name)
                 } yield found
                 test.map { found =>
                     assertEquals(found, None)
@@ -327,6 +386,44 @@
         }
     }
 
+    test("getMembers must return all members".tag(NeedsDatabase)) {
+        (genValidAccounts.sample, genOrganisation.sample) match {
+            case (Some(owner :: accounts), Some(org)) =>
+                val members = 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 addMember = repo.addMember(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        <- members.map(_.uid).traverse(addMember)
+                    foundMembers <- repo.getMembers(organisation.oid).compile.toList
+                } yield (written, added.sum, foundMembers)
+                test.map { result =>
+                    val (written, added, foundMembers) = result
+                    assert(written === 1, "Organisation not written to database!")
+                    assert(added === members.size, "Wrong number of members created!")
+                    assertEquals(foundMembers.map(_.uid).sorted, members.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)) =>
@@ -344,18 +441,44 @@
                     _ <- 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))
+                    added   <- accounts.traverse(admin => repo.addAdministrator(organisation.oid)(admin.uid))
+                    removed <- accounts.traverse(admin => repo.removeAdministrator(organisation.oid)(admin.uid))
+                } yield (written, added.sum, removed.sum)
                 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!")
-                    }
+                    assertEquals(added, removed)
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("removeMember must remove a member 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.traverse(member => repo.addMember(organisation.oid)(member.uid))
+                    removed   <- accounts.traverse(member => repo.removeMember(organisation.oid)(member.uid))
+                    foundOrgs <- accounts.traverse(member => repo.allByMember(member.uid).compile.toList)
+                } yield (written, added.sum, removed.sum, foundOrgs)
+                test.map { result =>
+                    val (written, added, removed, foundOrgs) = result
+                    assert(written === 1, "Organisation not written to database!")
+                    assertEquals(added, removed)
+                    foundOrgs.foreach(orgs => assert(orgs.isEmpty))
                 }
             case _ => fail("Could not generate data samples!")
         }