~jan0sch/smederee
Showing details for patch 2044b7d9feaed2f20029ff775bfa08cd169b7e12.
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. *