~jan0sch/smederee

Showing details for patch bb0956671017d8179c52bcb57e64b6902ab947b7.
2024-04-09 (Tue), 3:20 PM - Jens Grassel - bb0956671017d8179c52bcb57e64b6902ab947b7

Organisation administration

- add route and organisations tab to user settings
- add route to delete an organisation
  - only owners can delete organisations
  - delete directory of the organisation
- only owners can delete their organisations
- change base uri for organisation operations to not clash with repository
  routes
Summary of changes
1 files added
  • modules/hub/src/main/twirl/de/smederee/hub/views/account/settingsOrganisations.scala.html
10 files modified with 240 lines added and 47 lines removed
  • modules/hub/src/main/resources/assets/css/main.css with 5 added and 0 removed lines
  • modules/hub/src/main/resources/messages.properties with 21 added and 13 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala with 25 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 2 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala with 147 added and 25 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 7 added and 2 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html with 1 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisation.scala.html with 27 added and 2 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html with 1 added and 1 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html with 4 added and 2 removed lines
diff -rN -u old-smederee/modules/hub/src/main/resources/assets/css/main.css new-smederee/modules/hub/src/main/resources/assets/css/main.css
--- old-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-01-12 10:03:38.507669896 +0000
+++ new-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-01-12 10:03:38.507669896 +0000
@@ -250,6 +250,11 @@
   vertical-align: middle;
 }
 
+.organisation-delete-form {
+  border: 1px solid black;
+  padding: 0px 10px;
+}
+
 .overview-latest-changes {
   font-size: 85%;
 }
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-12 10:03:38.507669896 +0000
+++ new-smederee/modules/hub/src/main/resources/messages.properties	2025-01-12 10:03:38.507669896 +0000
@@ -41,23 +41,27 @@
 form.change-password.username.placeholder=Please enter your username.
 form.change-password.username=Username
 form.organisation.button.create.submit=Create organisation
+form.organisation.button.delete.submit=Delete this 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.delete.i-am-sure=Yes, I am sure that I want to delete this organisation and all related data!
+form.organisation.delete.notice=If you delete an organisation then all related data will be permanently removed. This action CANNOT be undone!
+form.organisation.delete.title=Delete this organisation!
+form.organisation.description.help=An optional short description of your organisation.
+form.organisation.description.placeholder=
+form.organisation.description=Description
 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.full-name.placeholder=
+form.organisation.full-name=Full Name
 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.is-private=Private Organisation
+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.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.owner=Owner
 form.organisation.website.help=An optional URI pointing to a website.
+form.organisation.website.placeholder=https://example.com
+form.organisation.website=Website
 form.create-repo.button.submit=Create repository
 form.create-repo.name=Name
 form.create-repo.name.placeholder=Please enter a repository name.
@@ -280,6 +284,10 @@
 user.settings.account.title=Account
 user.settings.account.validate-email.title=Validate your email address
 user.settings.language.title=You preferred language.
+user.settings.organisation.admins=Administrators
+user.settings.organisation.edit=Edit
+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.
 user.settings.ssh.description=Here you can manage your SSH keys.
 user.settings.ssh.key.created=Uploaded on {0,date,yyyy-MM-dd (E)}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	2025-01-12 10:03:38.507669896 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	2025-01-12 10:03:38.511669908 +0000
@@ -57,6 +57,8 @@
   *   The hub service configuration.
   * @param emailMiddleware
   *   Middleware layer needed to send emails.
+  * @param organisationsRepo
+  *   The repository providing functionality to handle organisations.
   * @param signAndValidate
   *   A class providing functions to handle session token signing and validation.
   * @param ticketServiceApi
@@ -68,6 +70,7 @@
     accountManagementRepo: AccountManagementRepository[F],
     configuration: ServiceConfig,
     emailMiddleware: EmailMiddleware[F],
+    organisationsRepo: OrganisationRepository[F],
     signAndValidate: SignAndValidate,
     ticketServiceApi: TicketServiceApi[F]
 ) extends Http4sDsl[F] {
@@ -420,6 +423,27 @@
             } yield resp
     }
 
+    private val showAccountOrganisations: AuthedRoutes[Account, F] = AuthedRoutes.of {
+        case ar @ GET -> Root / "user" / "settings" / "organisations" as user =>
+            for {
+                csrf          <- Sync[F].delay(ar.req.getCsrfToken)
+                language      <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+                actionBaseUri <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings"))
+                orgs          <- organisationsRepo.allByOwner(user.uid).compile.toList
+                organisationActionBaseUri <- Sync[F].delay(
+                    configuration.external.createFullUri(uri"user/settings/organisations")
+                )
+                resp <- Ok(
+                    views.html.account
+                        .settingsOrganisations(lang = language)(
+                            csrf,
+                            Option(s"Smederee/~${user.name} - Organisations"),
+                            user
+                        )(actionBaseUri, orgs, organisationActionBaseUri)
+                )
+            } yield resp
+    }
+
     private val showAccountSshSettings: AuthedRoutes[Account, F] = AuthedRoutes.of {
         case ar @ GET -> Root / "user" / "settings" / "ssh" as user =>
             for {
@@ -454,7 +478,7 @@
     }
 
     val protectedRoutes =
-        addSshKey <+> deleteAccount <+> deleteSshKey <+> sendValidationEmail <+> setLanguage <+> showAccountSettings <+> showAccountSshSettings
+        addSshKey <+> deleteAccount <+> deleteSshKey <+> sendValidationEmail <+> setLanguage <+> showAccountSettings <+> showAccountOrganisations <+> showAccountSshSettings
 
     val routes = validateEmailAddress
 
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-12 10:03:38.507669896 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-12 10:03:38.511669908 +0000
@@ -472,11 +472,13 @@
                     )
                     darcsWrapper          = new DarcsCommands[IO](hubConfiguration.service.darcs.executable)
                     emailMiddleware       = new SimpleJavaMailMiddleware(hubConfiguration.service.email)
+                    orgRepo               = new DoobieOrganisationRepository[IO](hubTransactor)
                     accountManagementRepo = new DoobieAccountManagementRepository[IO](hubTransactor)
                     accountManagementRoutes = new AccountManagementRoutes[IO](
                         accountManagementRepo,
                         hubConfiguration.service,
                         emailMiddleware,
+                        orgRepo,
                         signAndValidate,
                         ticketServiceApi
                     )
@@ -498,7 +500,6 @@
                     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](
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-12 10:03:38.507669896 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala	2025-01-12 10:03:38.511669908 +0000
@@ -17,6 +17,11 @@
 
 package de.smederee.hub
 
+import java.io.IOException
+import java.nio.file.FileVisitResult
+import java.nio.file.FileVisitor
+import java.nio.file.Files
+
 import cats.*
 import cats.data.*
 import cats.effect.*
@@ -48,9 +53,65 @@
     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 createOrgPath = uri"/user/settings/organisations/create"
     private val linkConfig    = configuration.external
 
+    /** Delete the given directory recursively. It is checked if the given directory is a direct sub directory of the
+      * `repositoriesDirectory` and only if this is the case is the directory removed.
+      *
+      * @param organisationDirectory
+      *   The path on the filesystem to the directory that shall be deleted.
+      * @return
+      *   `true` if the directory was deleted.
+      */
+    protected def deleteOrganisationDirectory(organisationDirectory: java.nio.file.Path): F[Boolean] =
+        for {
+            _            <- Sync[F].delay(log.debug(s"Request to delete organisation dir: $organisationDirectory"))
+            reposDirPath <- Sync[F].delay(configuration.darcs.repositoriesDirectory.toPath)
+            isSubDir     <- Sync[F].delay(reposDirPath.equals(organisationDirectory.getParent()))
+            deleted <-
+                Sync[F].delay {
+                    if (isSubDir) {
+                        Files.walkFileTree(
+                            organisationDirectory,
+                            new FileVisitor[java.nio.file.Path] {
+                                override def visitFileFailed(
+                                    file: java.nio.file.Path,
+                                    exc: IOException
+                                ): FileVisitResult = FileVisitResult.CONTINUE
+
+                                override def visitFile(
+                                    file: java.nio.file.Path,
+                                    attrs: java.nio.file.attribute.BasicFileAttributes
+                                ): FileVisitResult = {
+                                    Files.delete(file)
+                                    FileVisitResult.CONTINUE
+                                }
+
+                                override def preVisitDirectory(
+                                    dir: java.nio.file.Path,
+                                    attrs: java.nio.file.attribute.BasicFileAttributes
+                                ): FileVisitResult = FileVisitResult.CONTINUE
+
+                                override def postVisitDirectory(
+                                    dir: java.nio.file.Path,
+                                    exc: IOException
+                                ): FileVisitResult = {
+                                    Files.delete(dir)
+                                    FileVisitResult.CONTINUE
+                                }
+                            }
+                        )
+                        Files.deleteIfExists(organisationDirectory)
+                    } else {
+                        log.warn(
+                            s"Refused requested removal of directory $organisationDirectory which is not a direct sub directory of the configured repositories directory!"
+                        )
+                        false
+                    }
+                }
+        } yield deleted
+
     /** Load an organisation and related metadata from the database if the permissions allow it.
       *
       * @param currentUser
@@ -82,7 +143,7 @@
         } yield result
 
     private val createOrganisation: AuthedRoutes[Account, F] = AuthedRoutes.of {
-        case ar @ POST -> Root / "org" / "create" as user =>
+        case ar @ POST -> Root / "user" / "settings" / "organisations" / "create" as user =>
             ar.req.decodeStrict[F, UrlForm] { urlForm =>
                 for {
                     csrf     <- Sync[F].delay(ar.req.getCsrfToken)
@@ -147,8 +208,68 @@
             }
     }
 
+    private val deleteOrganisation: AuthedRoutes[Account, F] = AuthedRoutes.of {
+        case ar @ POST -> Root / "user" / "settings" / "organisations" / UsernamePathParameter(
+                organisationName
+            ) / "delete" 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))
+                    response <- orgaData match {
+                        case None => NotFound()
+                        case Some((organisation, _, 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)!
+                                    }
+                                }
+                                userIsSure <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes"))
+                                response <-
+                                    if (owner === user && userIsSure) {
+                                        for {
+                                            _ <- Sync[F].delay(
+                                                log.info(
+                                                    s"Going to delete organisation ${organisation.name} as requested by the owner ${user.uid}."
+                                                )
+                                            )
+                                            organisationDir <- Sync[F].delay(
+                                                java.nio.file.Paths
+                                                    .get(
+                                                        configuration.darcs.repositoriesDirectory.toPath.toString,
+                                                        organisation.name.toString
+                                                    )
+                                            )
+                                            _ <- deleteOrganisationDirectory(organisationDir)
+                                            _ <- orgRepo.delete(organisation.oid)
+                                            response <- SeeOther(
+                                                Location(linkConfig.createFullUri(Uri(path = Uri.Path.Root)))
+                                            )
+                                        } yield response
+                                    } else
+                                        SeeOther(Location(linkConfig.createFullUri(uri"user/settings/organisations")))
+                            } yield response
+                    }
+                } yield response
+            }
+    }
+
     private val editOrganisation: AuthedRoutes[Account, F] = AuthedRoutes.of {
-        case ar @ POST -> Root / UsernamePathParameter(organisationName) / "edit" as user =>
+        case ar @ POST -> Root / "user" / "settings" / "organisations" / UsernamePathParameter(
+                organisationName
+            ) / "edit" as user =>
             ar.req.decodeStrict[F, UrlForm] { urlForm =>
                 for {
                     csrf     <- Sync[F].delay(ar.req.getCsrfToken)
@@ -176,21 +297,21 @@
                                 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 actionBaseUri = uri"user/settings/organisations".addSegment(
+                                            s"~${organisation.name.toString}"
                                         )
+                                        val deleteOrgPath = linkConfig
+                                            .createFullUri(actionBaseUri.addSegment("delete"))
+                                            .some
+                                            .filter(_ => owner === user)
+                                        val editOrgPath =
+                                            linkConfig.createFullUri(actionBaseUri.addSegment("edit"))
                                         val possibleOwners = (List(owner, user) ::: admins).distinct
                                         BadRequest(
                                             views.html
                                                 .editOrganisation(lang = language)(
                                                     editOrgPath,
+                                                    deleteOrgPath,
                                                     csrf,
                                                     possibleOwners,
                                                     Option(s"~$organisationName - edit"),
@@ -222,7 +343,7 @@
     }
 
     private val showCreateOrganisationForm: AuthedRoutes[Account, F] = AuthedRoutes.of {
-        case ar @ GET -> Root / "org" / "create" as user =>
+        case ar @ GET -> Root / "user" / "settings" / "organisations" / "create" as user =>
             for {
                 csrf     <- Sync[F].delay(ar.req.getCsrfToken)
                 language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
@@ -252,7 +373,9 @@
     }
 
     private val showEditOrganisationForm: AuthedRoutes[Account, F] = AuthedRoutes.of {
-        case ar @ GET -> Root / UsernamePathParameter(organisationName) / "edit" as user =>
+        case ar @ GET -> Root / "user" / "settings" / "organisations" / UsernamePathParameter(
+                organisationName
+            ) / "edit" as user =>
             for {
                 csrf         <- Sync[F].delay(ar.req.getCsrfToken)
                 language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
@@ -272,22 +395,21 @@
                                         )
                                 )
                             case true =>
-                                val editOrgPath = linkConfig.createFullUri(
-                                    Uri(path =
-                                        Uri.Path(
-                                            Vector(
-                                                Uri.Path.Segment(s"~${organisation.name.toString}"),
-                                                Uri.Path.Segment("edit")
-                                            )
-                                        )
-                                    )
-                                )
+                                val actionBaseUri =
+                                    uri"user/settings/organisations".addSegment(s"~${organisation.name.toString}")
+                                val deleteOrgPath = linkConfig
+                                    .createFullUri(actionBaseUri.addSegment("delete"))
+                                    .some
+                                    .filter(_ => owner === user)
+                                val editOrgPath =
+                                    linkConfig.createFullUri(actionBaseUri.addSegment("edit"))
                                 val possibleOwners = (List(owner, user) ::: admins).distinct
                                 val formData       = OrganisationForm.from(organisation).toMap
                                 Ok(
                                     views.html
                                         .editOrganisation(lang = language)(
                                             editOrgPath,
+                                            deleteOrgPath,
                                             csrf,
                                             possibleOwners,
                                             Option(s"~$organisationName - edit"),
@@ -300,6 +422,6 @@
     }
 
     val protectedRoutes =
-        showCreateOrganisationForm <+> createOrganisation <+> showEditOrganisationForm <+> editOrganisation
+        showCreateOrganisationForm <+> createOrganisation <+> showEditOrganisationForm <+> deleteOrganisation <+> editOrganisation
 
 }
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-12 10:03:38.507669896 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-12 10:03:38.511669908 +0000
@@ -316,6 +316,11 @@
             actionBaseUri <- Sync[F].delay(
                 linkConfig.createFullUri(Uri(path = Uri.Path.unsafeFromString(s"~$repositoriesOwnerName")))
             )
+            organisationActionBaseUri = organisation.map(org =>
+                configuration.external.createFullUri(
+                    uri"user/settings/organisations".addSegment(s"~${org.name.toString}")
+                )
+            )
             resp <- (owner, organisation) match {
                 case (Some(owner), organisation) =>
                     loadRepos(owner).compile.toList.flatMap { repos =>
@@ -325,7 +330,7 @@
                                 csrf,
                                 s"Smederee/~$repositoriesOwnerName".some,
                                 user
-                            )(repos, repositoriesOwnerName, organisation)
+                            )(repos, repositoriesOwnerName, organisation, organisationActionBaseUri)
                         )
                     }
                 case (None, Some(organisation)) =>
@@ -335,7 +340,7 @@
                             csrf,
                             s"Smederee/~$repositoriesOwnerName".some,
                             user
-                        )(Nil, repositoriesOwnerName, organisation.some)
+                        )(Nil, repositoriesOwnerName, organisation.some, organisationActionBaseUri)
                     )
                 case _ =>
                     NotFound(
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settingsOrganisations.scala.html	2025-01-12 10:03:38.511669908 +0000
@@ -0,0 +1,42 @@
+@import de.smederee.hub.*
+@import de.smederee.hub.views.html.*
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(actionBaseUri: Uri, organisations: List[Organisation], organisationActionBaseUri: Uri)
+@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-left-right">
+        <h2>~@user.name / Settings</h2>
+        <nav class="pure-menu pure-menu-horizontal">
+          <ul class="pure-menu-list">
+            <li class="pure-menu-item"><a class="pure-menu-link" href="@actionBaseUri">@Messages("user.settings.account.title")</a></li>
+            <li class="pure-menu-item"><a class="pure-menu-link" href="@actionBaseUri.addSegment("ssh")">@Messages("user.settings.ssh.title")</a></li>
+            <li class="pure-menu-item pure-menu-active"><a class="pure-menu-link" href="@actionBaseUri.addSegment("organisations")">@Messages("user.settings.organisations.title")</a></li>
+          </ul>
+        </nav>
+        <div class="account-settings-description">
+          @Messages("user.settings.organisations.description")
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        @for(organisation <- organisations) {
+          @defining(organisation.name) { organisationName =>
+          <div class="pure-g">
+            <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>
+          }
+        }
+      </div>
+    </div>
+  </div>
+</div>
+}
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html	2025-01-12 10:03:38.507669896 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html	2025-01-12 10:03:38.511669908 +0000
@@ -14,6 +14,7 @@
           <ul class="pure-menu-list">
             <li class="pure-menu-item pure-menu-active"><a class="pure-menu-link" href="@actionBaseUri">@Messages("user.settings.account.title")</a></li>
             <li class="pure-menu-item"><a class="pure-menu-link" href="@actionBaseUri.addSegment("ssh")">@Messages("user.settings.ssh.title")</a></li>
+            <li class="pure-menu-item"><a class="pure-menu-link" href="@actionBaseUri.addSegment("organisations")">@Messages("user.settings.organisations.title")</a></li>
           </ul>
         </nav>
         <div class="account-settings-description">
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	2025-01-12 10:03:38.507669896 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editOrganisation.scala.html	2025-01-12 10:03:38.511669908 +0000
@@ -3,7 +3,7 @@
 @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)
+@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(editAction: Uri, deleteAction: Option[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">
@@ -22,7 +22,7 @@
             }
           </div>
           <div class="organisation-form">
-            <form action="@action" method="POST" accept-charset="UTF-8" class="pure-form pure-form-aligned">
+            <form action="@editAction" method="POST" accept-charset="UTF-8" class="pure-form pure-form-aligned">
               <fieldset id="organisation-data">
                 @organisationFormFields(possibleOwners)(formData, formErrors)
                 @csrfToken(csrf)
@@ -35,6 +35,31 @@
         </div>
       </div>
     </div>
+    @for(deleteAction <- deleteAction) {
+    <div class="pure-g">
+      <div class="pure-u-1-1 pure-u-md-1-1">
+        <div class="l-box">
+          <div class="organisation-delete-form">
+            <h4>@Messages("form.organisation.delete.title")</h4>
+            @defining(formData.get(fieldName)) { organisationName =>
+            <form action="@deleteAction" class="pure-form pure-form-aligned" method="POST" accept-charset="UTF-8">
+              <fieldset>
+                <p class="alert alert-error">
+                  <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+                  @Messages("form.organisation.delete.notice")
+                </p>
+                <input type="hidden" id="org-@{organisationName}" name="org-name" readonly="" value="@{organisationName}">
+                <label class="pure-checkbox" for="org-@{organisationName}"><input id="i-am-sure-@{organisationName}" name="i-am-sure" required="" type="checkbox" value="yes"/> @Messages("form.organisation.delete.i-am-sure")</label>
+                @csrfToken(csrf)
+                <button type="submit" class="pure-button pure-button-warning">@Messages("form.organisation.button.delete.submit")</button>
+              </fieldset>
+            </form>
+            }
+          </div>
+        </div>
+      </div>
+    </div>
+    }
   </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-12 10:03:38.507669896 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html	2025-01-12 10:03:38.511669908 +0000
@@ -12,7 +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/organisations/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-12 10:03:38.507669896 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html	2025-01-12 10:03:38.511669908 +0000
@@ -1,7 +1,7 @@
 @import de.smederee.hub.*
 @import de.smederee.security.Username
 
-@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Option[Account])(listing: List[VcsRepository], repositoriesOwner: Username, organisation: Option[Organisation])
+@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Option[Account])(listing: List[VcsRepository], repositoriesOwner: Username, organisation: Option[Organisation], organisationActionBaseUri: Option[Uri])
 @main(baseUri, lang)()(csrf, title, user) {
 @defining(lang.toLocale) { implicit locale =>
   <div class="content">
@@ -13,9 +13,11 @@
       </div>
       <div class="pure-u-1-5 pure-u-md-1-5">
         @if(user.exists(user => organisation.exists(_.owner === user.uid))) {
+          @defining(organisationActionBaseUri.map(_.addSegment("edit"))) { orgEditUri =>
           <div class="l-box">
-            <a href="@actionBaseUri.addSegment("edit")">@Messages("organisation.menu.edit")</a>
+            <a href="@orgEditUri">@Messages("organisation.menu.edit")</a>
           </div>
+          }
         } else {
         }
       </div>