From 5064a60e7693346387a7d500967af9730f1b595e Mon Sep 17 00:00:00 2001 From: Florian M Date: Thu, 15 Dec 2022 15:43:07 +0100 Subject: [PATCH 1/5] add protected and private modifiers to DAO hierarchy (#6698) * add protected and private modifiers to DAO hierarchy * format --- app/WebKnossosModule.scala | 1 - app/controllers/InitialDataController.scala | 6 +- app/controllers/TeamController.scala | 14 ++- app/models/annotation/Annotation.scala | 28 +++--- .../annotation/AnnotationPrivateLink.scala | 10 +- app/models/annotation/AnnotationService.scala | 7 +- app/models/annotation/TracingStore.scala | 8 +- app/models/binary/DataSet.scala | 12 +-- app/models/binary/DataStore.scala | 10 +- app/models/binary/Publication.scala | 8 +- app/models/folder/Folder.scala | 16 ++-- app/models/job/Job.scala | 10 +- app/models/job/Worker.scala | 8 +- app/models/mesh/Mesh.scala | 10 +- app/models/organization/Organization.scala | 12 +-- app/models/project/Project.scala | 12 +-- app/models/shortlinks/ShortLink.scala | 8 +- app/models/task/Script.scala | 10 +- app/models/task/Task.scala | 12 +-- app/models/task/TaskCreationService.scala | 6 +- app/models/task/TaskType.scala | 12 +-- app/models/team/Team.scala | 12 +-- app/models/user/Invite.scala | 8 +- app/models/user/MultiUser.scala | 8 +- app/models/user/User.scala | 36 ++++---- app/models/user/UserService.scala | 9 +- app/models/user/time/TimeSpan.scala | 8 +- app/oxalis/security/Token.scala | 8 +- app/utils/SQLHelpers.scala | 92 +++++++++---------- 29 files changed, 193 insertions(+), 208 deletions(-) diff --git a/app/WebKnossosModule.scala b/app/WebKnossosModule.scala index 951a8858bea..105bee3fa1e 100644 --- a/app/WebKnossosModule.scala +++ b/app/WebKnossosModule.scala @@ -21,7 +21,6 @@ class WebKnossosModule extends AbstractModule { bind(classOf[UserService]).asEagerSingleton() bind(classOf[TaskService]).asEagerSingleton() bind(classOf[UserDAO]).asEagerSingleton() - bind(classOf[UserTeamRolesDAO]).asEagerSingleton() bind(classOf[UserExperiencesDAO]).asEagerSingleton() bind(classOf[UserDataSetConfigurationDAO]).asEagerSingleton() bind(classOf[UserCache]).asEagerSingleton() diff --git a/app/controllers/InitialDataController.scala b/app/controllers/InitialDataController.scala index c4a9357248b..804ab45ce4c 100644 --- a/app/controllers/InitialDataController.scala +++ b/app/controllers/InitialDataController.scala @@ -38,7 +38,6 @@ class InitialDataController @Inject()(initialDataService: InitialDataService, si class InitialDataService @Inject()(userService: UserService, userDAO: UserDAO, multiUserDAO: MultiUserDAO, - userTeamRolesDAO: UserTeamRolesDAO, userExperiencesDAO: UserExperiencesDAO, taskTypeDAO: TaskTypeDAO, dataStoreDAO: DataStoreDAO, @@ -182,9 +181,8 @@ Samplecountry _ <- multiUserDAO.insertOne(multiUser) _ <- userDAO.insertOne(user) _ <- userExperiencesDAO.updateExperiencesForUser(user, Map("sampleExp" -> 10)) - _ <- userTeamRolesDAO.insertTeamMembership( - user._id, - TeamMembership(organizationTeam._id, isTeamManager = isTeamManager)) + _ <- userDAO.insertTeamMembership(user._id, + TeamMembership(organizationTeam._id, isTeamManager = isTeamManager)) _ = logger.info("Inserted default user") } yield () } diff --git a/app/controllers/TeamController.scala b/app/controllers/TeamController.scala index e757a275f35..b58fe9a525a 100755 --- a/app/controllers/TeamController.scala +++ b/app/controllers/TeamController.scala @@ -4,21 +4,19 @@ import com.mohiva.play.silhouette.api.Silhouette import com.scalableminds.util.tools.Fox import io.swagger.annotations._ import models.team._ -import models.user.UserTeamRolesDAO +import models.user.UserDAO import oxalis.security.WkEnv import play.api.i18n.Messages import play.api.libs.json._ -import utils.ObjectId -import javax.inject.Inject import play.api.mvc.{Action, AnyContent} +import utils.ObjectId +import javax.inject.Inject import scala.concurrent.ExecutionContext @Api -class TeamController @Inject()(teamDAO: TeamDAO, - userTeamRolesDAO: UserTeamRolesDAO, - teamService: TeamService, - sil: Silhouette[WkEnv])(implicit ec: ExecutionContext) +class TeamController @Inject()(teamDAO: TeamDAO, userDAO: UserDAO, teamService: TeamService, sil: Silhouette[WkEnv])( + implicit ec: ExecutionContext) extends Controller { private def teamNameReads: Reads[String] = @@ -46,7 +44,7 @@ class TeamController @Inject()(teamDAO: TeamDAO, _ <- bool2Fox(!team.isOrganizationTeam) ?~> "team.delete.organizationTeam" ~> FORBIDDEN _ <- teamService.assertNoReferences(teamIdValidated) ?~> "team.delete.inUse" ~> FORBIDDEN _ <- teamDAO.deleteOne(teamIdValidated) - _ <- userTeamRolesDAO.removeTeamFromAllUsers(teamIdValidated) + _ <- userDAO.removeTeamFromAllUsers(teamIdValidated) _ <- teamDAO.removeTeamFromAllDatasetsAndFolders(teamIdValidated) } yield JsonOk(Messages("team.deleted")) } diff --git a/app/models/annotation/Annotation.scala b/app/models/annotation/Annotation.scala index ad0115cb91d..2b6d1182159 100755 --- a/app/models/annotation/Annotation.scala +++ b/app/models/annotation/Annotation.scala @@ -144,12 +144,12 @@ class AnnotationLayerDAO @Inject()(SQLClient: SQLClient)(implicit ec: ExecutionC class AnnotationDAO @Inject()(sqlClient: SQLClient, annotationLayerDAO: AnnotationLayerDAO)( implicit ec: ExecutionContext) extends SQLDAO[Annotation, AnnotationsRow, Annotations](sqlClient) { - val collection = Annotations + protected val collection = Annotations - def idColumn(x: Annotations): Rep[String] = x._Id - def isDeletedColumn(x: Annotations): Rep[Boolean] = x.isdeleted + protected def idColumn(x: Annotations): Rep[String] = x._Id + protected def isDeletedColumn(x: Annotations): Rep[Boolean] = x.isdeleted - def parse(r: AnnotationsRow): Fox[Annotation] = + protected def parse(r: AnnotationsRow): Fox[Annotation] = for { state <- AnnotationState.fromString(r.state).toFox typ <- AnnotationType.fromString(r.typ).toFox @@ -180,7 +180,8 @@ class AnnotationDAO @Inject()(sqlClient: SQLClient, annotationLayerDAO: Annotati ) } - override def anonymousReadAccessQ(sharingToken: Option[String]) = s"visibility = '${AnnotationVisibility.Public}'" + override protected def anonymousReadAccessQ(sharingToken: Option[String]) = + s"visibility = '${AnnotationVisibility.Public}'" private def listAccessQ(requestingUserId: ObjectId): String = s""" @@ -206,7 +207,7 @@ class AnnotationDAO @Inject()(sqlClient: SQLClient, annotationLayerDAO: Annotati ) """ - override def readAccessQ(requestingUserId: ObjectId): String = + override protected def readAccessQ(requestingUserId: ObjectId): String = s"""( visibility = '${AnnotationVisibility.Public}' or (visibility = '${AnnotationVisibility.Internal}' @@ -218,12 +219,12 @@ class AnnotationDAO @Inject()(sqlClient: SQLClient, annotationLayerDAO: Annotati in (select _organization from webknossos.users_ where _id = '$requestingUserId' and isAdmin) )""" - override def deleteAccessQ(requestingUserId: ObjectId) = + override protected def deleteAccessQ(requestingUserId: ObjectId) = s"""(_team in (select _team from webknossos.user_team_roles where isTeamManager and _user = '$requestingUserId') or _user = '$requestingUserId' or (select _organization from webknossos.teams where webknossos.teams._id = _team) in (select _organization from webknossos.users_ where _id = '$requestingUserId' and isAdmin))""" - override def updateAccessQ(requestingUserId: ObjectId): String = + override protected def updateAccessQ(requestingUserId: ObjectId): String = deleteAccessQ(requestingUserId) // read operations @@ -543,20 +544,16 @@ class AnnotationDAO @Inject()(sqlClient: SQLClient, annotationLayerDAO: Annotati _ <- run( sqlu"insert into webknossos.annotation_contributors (_annotation, _user) values($id, $userId) on conflict do nothing") } yield () -} - -class SharedAnnotationsDAO @Inject()(annotationDAO: AnnotationDAO, sqlClient: SQLClient)(implicit ec: ExecutionContext) - extends SimpleSQLDAO(sqlClient) { // Does not use access query (because they dont support prefixes). Use only after separate access check! def findAllSharedForTeams(teams: List[ObjectId]): Fox[List[Annotation]] = for { result <- run( - sql"""select distinct #${annotationDAO.columnsWithPrefix("a.")} from webknossos.annotations_ a + sql"""select distinct #${columnsWithPrefix("a.")} from webknossos.annotations_ a join webknossos.annotation_sharedTeams l on a._id = l._annotation where l._team in #${writeStructTupleWithQuotes(teams.map(t => sanitize(t.toString)))}""" .as[AnnotationsRow]) - parsed <- Fox.combined(result.toList.map(annotationDAO.parse)) + parsed <- Fox.combined(result.toList.map(parse)) } yield parsed def updateTeamsForSharedAnnotation(annotationId: ObjectId, teams: List[ObjectId])( @@ -568,11 +565,10 @@ class SharedAnnotationsDAO @Inject()(annotationDAO: AnnotationDAO, sqlClient: SQ val composedQuery = DBIO.sequence(List(clearQuery) ++ insertQueries) for { - _ <- annotationDAO.assertUpdateAccess(annotationId) + _ <- assertUpdateAccess(annotationId) _ <- run(composedQuery.transactionally.withTransactionIsolation(Serializable), retryCount = 50, retryIfErrorContains = List(transactionSerializationError)) } yield () } - } diff --git a/app/models/annotation/AnnotationPrivateLink.scala b/app/models/annotation/AnnotationPrivateLink.scala index 2ccc68e4f10..ec533296380 100644 --- a/app/models/annotation/AnnotationPrivateLink.scala +++ b/app/models/annotation/AnnotationPrivateLink.scala @@ -45,13 +45,13 @@ class AnnotationPrivateLinkService @Inject()()(implicit ec: ExecutionContext) { class AnnotationPrivateLinkDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[AnnotationPrivateLink, AnnotationPrivatelinksRow, AnnotationPrivatelinks](sqlClient) { - val collection = AnnotationPrivatelinks + protected val collection = AnnotationPrivatelinks - def idColumn(x: AnnotationPrivatelinks): Rep[String] = x._Id + protected def idColumn(x: AnnotationPrivatelinks): Rep[String] = x._Id - def isDeletedColumn(x: AnnotationPrivatelinks): Rep[Boolean] = x.isdeleted + protected def isDeletedColumn(x: AnnotationPrivatelinks): Rep[Boolean] = x.isdeleted - def parse(r: AnnotationPrivatelinksRow): Fox[AnnotationPrivateLink] = + protected def parse(r: AnnotationPrivatelinksRow): Fox[AnnotationPrivateLink] = Fox.successful( AnnotationPrivateLink( ObjectId(r._Id), @@ -62,7 +62,7 @@ class AnnotationPrivateLinkDAO @Inject()(sqlClient: SQLClient)(implicit ec: Exec ) ) - override def readAccessQ(requestingUserId: ObjectId): String = + override protected def readAccessQ(requestingUserId: ObjectId): String = s"""(_annotation in (select _id from webknossos.annotations_ where _user = '${requestingUserId.id}'))""" def insertOne(aPL: AnnotationPrivateLink): Fox[Unit] = diff --git a/app/models/annotation/AnnotationService.scala b/app/models/annotation/AnnotationService.scala index 84a73e94adb..bd9fcea1e1c 100755 --- a/app/models/annotation/AnnotationService.scala +++ b/app/models/annotation/AnnotationService.scala @@ -105,8 +105,7 @@ class AnnotationService @Inject()( nmlWriter: NmlWriter, temporaryFileCreator: TemporaryFileCreator, meshDAO: MeshDAO, - meshService: MeshService, - sharedAnnotationsDAO: SharedAnnotationsDAO + meshService: MeshService )(implicit ec: ExecutionContext, val materializer: Materializer) extends BoxImplicits with FoxImplicits @@ -589,11 +588,11 @@ class AnnotationService @Inject()( // Does not use access query (because they dont support prefixes). Use only after separate access check! def sharedAnnotationsFor(userTeams: List[ObjectId]): Fox[List[Annotation]] = - sharedAnnotationsDAO.findAllSharedForTeams(userTeams) + annotationDAO.findAllSharedForTeams(userTeams) def updateTeamsForSharedAnnotation(annotationId: ObjectId, teams: List[ObjectId])( implicit ctx: DBAccessContext): Fox[Unit] = - sharedAnnotationsDAO.updateTeamsForSharedAnnotation(annotationId, teams) + annotationDAO.updateTeamsForSharedAnnotation(annotationId, teams) def zipAnnotations(annotations: List[Annotation], zipFileName: String, skipVolumeData: Boolean)( implicit diff --git a/app/models/annotation/TracingStore.scala b/app/models/annotation/TracingStore.scala index c501094d3c4..44776df99ae 100644 --- a/app/models/annotation/TracingStore.scala +++ b/app/models/annotation/TracingStore.scala @@ -59,12 +59,12 @@ class TracingStoreService @Inject()(tracingStoreDAO: TracingStoreDAO, rpc: RPC)( class TracingStoreDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[TracingStore, TracingstoresRow, Tracingstores](sqlClient) { - val collection = Tracingstores + protected val collection = Tracingstores - def idColumn(x: Tracingstores): Rep[String] = x.name - def isDeletedColumn(x: Tracingstores): Rep[Boolean] = x.isdeleted + protected def idColumn(x: Tracingstores): Rep[String] = x.name + protected def isDeletedColumn(x: Tracingstores): Rep[Boolean] = x.isdeleted - def parse(r: TracingstoresRow): Fox[TracingStore] = + protected def parse(r: TracingstoresRow): Fox[TracingStore] = Fox.successful( TracingStore( r.name, diff --git a/app/models/binary/DataSet.scala b/app/models/binary/DataSet.scala index 3f72377403a..03868cd5882 100755 --- a/app/models/binary/DataSet.scala +++ b/app/models/binary/DataSet.scala @@ -62,11 +62,11 @@ class DataSetDAO @Inject()(sqlClient: SQLClient, dataSetDataLayerDAO: DataSetDataLayerDAO, organizationDAO: OrganizationDAO)(implicit ec: ExecutionContext) extends SQLDAO[DataSet, DatasetsRow, Datasets](sqlClient) { - val collection = Datasets + protected val collection = Datasets - def idColumn(x: Datasets): Rep[String] = x._Id + protected def idColumn(x: Datasets): Rep[String] = x._Id - def isDeletedColumn(x: Datasets): Rep[Boolean] = x.isdeleted + protected def isDeletedColumn(x: Datasets): Rep[Boolean] = x.isdeleted private def parseScaleOpt(literalOpt: Option[String]): Fox[Option[Vec3Double]] = literalOpt match { case Some(literal) => @@ -79,7 +79,7 @@ class DataSetDAO @Inject()(sqlClient: SQLClient, private def writeScaleLiteral(scale: Vec3Double): String = writeStructTuple(List(scale.x, scale.y, scale.z).map(_.toString)) - def parse(r: DatasetsRow): Fox[DataSet] = + protected def parse(r: DatasetsRow): Fox[DataSet] = for { scale <- parseScaleOpt(r.scale) defaultViewConfigurationOpt <- Fox.runOptional(r.defaultviewconfiguration)( @@ -405,7 +405,7 @@ class DataSetDAO @Inject()(sqlClient: SQLClient, class DataSetResolutionsDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SimpleSQLDAO(sqlClient) { - def parseRow(row: DatasetResolutionsRow): Fox[Vec3Int] = + private def parseRow(row: DatasetResolutionsRow): Fox[Vec3Int] = for { resolution <- Vec3Int.fromList(parseArrayTuple(row.resolution).map(_.toInt)) ?~> "could not parse resolution" } yield resolution @@ -447,7 +447,7 @@ class DataSetDataLayerDAO @Inject()(sqlClient: SQLClient, dataSetResolutionsDAO: implicit ec: ExecutionContext) extends SimpleSQLDAO(sqlClient) { - def parseRow(row: DatasetLayersRow, dataSetId: ObjectId, skipResolutions: Boolean = false): Fox[DataLayer] = { + private def parseRow(row: DatasetLayersRow, dataSetId: ObjectId, skipResolutions: Boolean): Fox[DataLayer] = { val result: Fox[Fox[DataLayer]] = for { category <- Category.fromString(row.category).toFox ?~> "Could not parse Layer Category" boundingBox <- BoundingBox diff --git a/app/models/binary/DataStore.scala b/app/models/binary/DataStore.scala index cba1ca7602d..21dbc00a33d 100644 --- a/app/models/binary/DataStore.scala +++ b/app/models/binary/DataStore.scala @@ -82,15 +82,15 @@ class DataStoreService @Inject()(dataStoreDAO: DataStoreDAO)(implicit ec: Execut class DataStoreDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[DataStore, DatastoresRow, Datastores](sqlClient) { - val collection = Datastores + protected val collection = Datastores - def idColumn(x: Datastores): Rep[String] = x.name - def isDeletedColumn(x: Datastores): Rep[Boolean] = x.isdeleted + protected def idColumn(x: Datastores): Rep[String] = x.name + protected def isDeletedColumn(x: Datastores): Rep[Boolean] = x.isdeleted - override def readAccessQ(requestingUserId: ObjectId): String = + override protected def readAccessQ(requestingUserId: ObjectId): String = s"(onlyAllowedOrganization is null) OR (onlyAllowedOrganization in (select _organization from webknossos.users_ where _id = '$requestingUserId'))" - def parse(r: DatastoresRow): Fox[DataStore] = + protected def parse(r: DatastoresRow): Fox[DataStore] = Fox.successful( DataStore( r.name, diff --git a/app/models/binary/Publication.scala b/app/models/binary/Publication.scala index c153a34fdc8..10d124a3018 100644 --- a/app/models/binary/Publication.scala +++ b/app/models/binary/Publication.scala @@ -54,13 +54,13 @@ class PublicationService @Inject()(dataSetService: DataSetService, class PublicationDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[Publication, PublicationsRow, Publications](sqlClient) { - val collection = Publications + protected val collection = Publications - def idColumn(x: Publications): Rep[String] = x._Id + protected def idColumn(x: Publications): Rep[String] = x._Id - def isDeletedColumn(x: Publications): Rep[Boolean] = x.isdeleted + protected def isDeletedColumn(x: Publications): Rep[Boolean] = x.isdeleted - def parse(r: PublicationsRow): Fox[Publication] = + protected def parse(r: PublicationsRow): Fox[Publication] = Fox.successful( Publication( ObjectId(r._Id), diff --git a/app/models/folder/Folder.scala b/app/models/folder/Folder.scala index 30bac9822df..656e75552cd 100644 --- a/app/models/folder/Folder.scala +++ b/app/models/folder/Folder.scala @@ -75,22 +75,22 @@ class FolderService @Inject()(teamDAO: TeamDAO, class FolderDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[Folder, FoldersRow, Folders](sqlClient) { - val collection = Folders - def idColumn(x: Folders): Rep[String] = x._Id - def isDeletedColumn(x: Folders): Rep[Boolean] = x.isdeleted + protected val collection = Folders + protected def idColumn(x: Folders): Rep[String] = x._Id + protected def isDeletedColumn(x: Folders): Rep[Boolean] = x.isdeleted - def parse(r: FoldersRow): Fox[Folder] = + protected def parse(r: FoldersRow): Fox[Folder] = Fox.successful(Folder(ObjectId(r._Id), r.name)) - def parseWithParent(t: (String, String, Option[String])): Fox[FolderWithParent] = + private def parseWithParent(t: (String, String, Option[String])): Fox[FolderWithParent] = Fox.successful(FolderWithParent(ObjectId(t._1), t._2, t._3.map(ObjectId(_)))) - override def readAccessQ(requestingUserId: ObjectId): String = readAccessQWithPrefix(requestingUserId, "") + override protected def readAccessQ(requestingUserId: ObjectId): String = readAccessQWithPrefix(requestingUserId, "") - def readAccessQWithPrefix(requestingUserId: ObjectId, prefix: String): String = + private def readAccessQWithPrefix(requestingUserId: ObjectId, prefix: String): String = rawAccessQ(write = false, requestingUserId, prefix) - override def updateAccessQ(requestingUserId: ObjectId): String = + override protected def updateAccessQ(requestingUserId: ObjectId): String = rawAccessQ(write = true, requestingUserId, prefix = "") private def rawAccessQ(write: Boolean, requestingUserId: ObjectId, prefix: String): String = { diff --git a/app/models/job/Job.scala b/app/models/job/Job.scala index a50d44aab6f..8bd16732573 100644 --- a/app/models/job/Job.scala +++ b/app/models/job/Job.scala @@ -92,12 +92,12 @@ case class Job( class JobDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[Job, JobsRow, Jobs](sqlClient) { - val collection = Jobs + protected val collection = Jobs - def idColumn(x: Jobs): Rep[String] = x._Id - def isDeletedColumn(x: Jobs): Rep[Boolean] = x.isdeleted + protected def idColumn(x: Jobs): Rep[String] = x._Id + protected def isDeletedColumn(x: Jobs): Rep[Boolean] = x.isdeleted - def parse(r: JobsRow): Fox[Job] = + protected def parse(r: JobsRow): Fox[Job] = for { manualStateOpt <- Fox.runOptional(r.manualstate)(JobState.fromString) state <- JobState.fromString(r.state) @@ -120,7 +120,7 @@ class JobDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) ) } - override def readAccessQ(requestingUserId: ObjectId) = + override protected def readAccessQ(requestingUserId: ObjectId) = s"""_owner = '$requestingUserId'""" override def findAll(implicit ctx: DBAccessContext): Fox[List[Job]] = diff --git a/app/models/job/Worker.scala b/app/models/job/Worker.scala index 4ab64b52e6d..0fbe7ee49da 100644 --- a/app/models/job/Worker.scala +++ b/app/models/job/Worker.scala @@ -30,13 +30,13 @@ case class Worker(_id: ObjectId, class WorkerDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[Worker, WorkersRow, Workers](sqlClient) { - val collection = Workers + protected val collection = Workers - def idColumn(x: Workers): Rep[String] = x._Id + protected def idColumn(x: Workers): Rep[String] = x._Id - def isDeletedColumn(x: Workers): Rep[Boolean] = x.isdeleted + protected def isDeletedColumn(x: Workers): Rep[Boolean] = x.isdeleted - def parse(r: WorkersRow): Fox[Worker] = + protected def parse(r: WorkersRow): Fox[Worker] = Fox.successful( Worker( ObjectId(r._Id), diff --git a/app/models/mesh/Mesh.scala b/app/models/mesh/Mesh.scala index ed4cb022bd9..3f20ab9be3e 100644 --- a/app/models/mesh/Mesh.scala +++ b/app/models/mesh/Mesh.scala @@ -55,19 +55,19 @@ class MeshService @Inject()()(implicit ec: ExecutionContext) { class MeshDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[MeshInfo, MeshesRow, Meshes](sqlClient) { - val collection = Meshes + protected val collection = Meshes - def idColumn(x: Meshes): Rep[String] = x._Id + protected def idColumn(x: Meshes): Rep[String] = x._Id - def isDeletedColumn(x: Meshes): Rep[Boolean] = x.isdeleted + protected def isDeletedColumn(x: Meshes): Rep[Boolean] = x.isdeleted private val infoColumns = (columnsList diff Seq("data")).mkString(", ") type InfoTuple = (ObjectId, ObjectId, String, String, Instant, Boolean) - override def parse(r: MeshesRow): Fox[MeshInfo] = + override protected def parse(r: MeshesRow): Fox[MeshInfo] = Fox.failure("not implemented, use parseInfo or get the data directly") - def parseInfo(r: InfoTuple): Fox[MeshInfo] = + private def parseInfo(r: InfoTuple): Fox[MeshInfo] = for { position <- Vec3Int.fromList(parseArrayTuple(r._4).map(_.toInt)) ?~> "could not parse mesh position" } yield { diff --git a/app/models/organization/Organization.scala b/app/models/organization/Organization.scala index f46b424284e..3b912c774a3 100755 --- a/app/models/organization/Organization.scala +++ b/app/models/organization/Organization.scala @@ -31,13 +31,13 @@ case class Organization( class OrganizationDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[Organization, OrganizationsRow, Organizations](sqlClient) { - val collection = Organizations + protected val collection = Organizations - def idColumn(x: Organizations): Rep[String] = x._Id + protected def idColumn(x: Organizations): Rep[String] = x._Id - def isDeletedColumn(x: Organizations): Rep[Boolean] = x.isdeleted + protected def isDeletedColumn(x: Organizations): Rep[Boolean] = x.isdeleted - def parse(r: OrganizationsRow): Fox[Organization] = + protected def parse(r: OrganizationsRow): Fox[Organization] = for { pricingPlan <- PricingPlan.fromString(r.pricingplan).toFox } yield { @@ -57,11 +57,11 @@ class OrganizationDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionCont ) } - override def readAccessQ(requestingUserId: ObjectId): String = + override protected def readAccessQ(requestingUserId: ObjectId): String = s"((_id in (select _organization from webknossos.users_ where _multiUser = (select _multiUser from webknossos.users_ where _id = '$requestingUserId')))" + s"or 'true' in (select isSuperUser from webknossos.multiUsers_ where _id in (select _multiUser from webknossos.users_ where _id = '$requestingUserId')))" - override def anonymousReadAccessQ(sharingToken: Option[String]): String = sharingToken match { + override protected def anonymousReadAccessQ(sharingToken: Option[String]): String = sharingToken match { case Some(_) => "true" case _ => "false" } diff --git a/app/models/project/Project.scala b/app/models/project/Project.scala index 6e5d3407f82..b0a825cd57e 100755 --- a/app/models/project/Project.scala +++ b/app/models/project/Project.scala @@ -57,12 +57,12 @@ object Project { class ProjectDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[Project, ProjectsRow, Projects](sqlClient) { - val collection = Projects + protected val collection = Projects - def idColumn(x: Projects): Rep[String] = x._Id - def isDeletedColumn(x: Projects): Rep[Boolean] = x.isdeleted + protected def idColumn(x: Projects): Rep[String] = x._Id + protected def isDeletedColumn(x: Projects): Rep[Boolean] = x.isdeleted - def parse(r: ProjectsRow): Fox[Project] = + protected def parse(r: ProjectsRow): Fox[Project] = Fox.successful( Project( ObjectId(r._Id), @@ -77,13 +77,13 @@ class ProjectDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) r.isdeleted )) - override def readAccessQ(requestingUserId: ObjectId) = + override protected def readAccessQ(requestingUserId: ObjectId) = s"""( (_team in (select _team from webknossos.user_team_roles where _user = '${requestingUserId.id}')) or _owner = '${requestingUserId.id}' or _organization = (select _organization from webknossos.users_ where _id = '${requestingUserId.id}' and isAdmin) )""" - override def deleteAccessQ(requestingUserId: ObjectId) = s"_owner = '${requestingUserId.id}'" + override protected def deleteAccessQ(requestingUserId: ObjectId) = s"_owner = '${requestingUserId.id}'" // read operations diff --git a/app/models/shortlinks/ShortLink.scala b/app/models/shortlinks/ShortLink.scala index 079ebfe82ef..03b7d5dbd76 100644 --- a/app/models/shortlinks/ShortLink.scala +++ b/app/models/shortlinks/ShortLink.scala @@ -19,13 +19,13 @@ object ShortLink { class ShortLinkDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[ShortLink, ShortlinksRow, Shortlinks](sqlClient) { - val collection = Shortlinks + protected val collection = Shortlinks - def idColumn(x: Shortlinks): Rep[String] = x._Id + protected def idColumn(x: Shortlinks): Rep[String] = x._Id - override def isDeletedColumn(x: Tables.Shortlinks): Rep[Boolean] = false + override protected def isDeletedColumn(x: Tables.Shortlinks): Rep[Boolean] = false - def parse(r: ShortlinksRow): Fox[ShortLink] = + protected def parse(r: ShortlinksRow): Fox[ShortLink] = Fox.successful( ShortLink( ObjectId(r._Id), diff --git a/app/models/task/Script.scala b/app/models/task/Script.scala index 29c23d6110b..0e540f7ed41 100644 --- a/app/models/task/Script.scala +++ b/app/models/task/Script.scala @@ -47,15 +47,15 @@ object Script { class ScriptDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[Script, ScriptsRow, Scripts](sqlClient) { - val collection = Scripts + protected val collection = Scripts - def idColumn(x: Scripts): Rep[String] = x._Id - def isDeletedColumn(x: Scripts): Rep[Boolean] = x.isdeleted + protected def idColumn(x: Scripts): Rep[String] = x._Id + protected def isDeletedColumn(x: Scripts): Rep[Boolean] = x.isdeleted - override def readAccessQ(requestingUserId: ObjectId): String = + override protected def readAccessQ(requestingUserId: ObjectId): String = s"(select _organization from webknossos.users_ u where u._id = _owner) = (select _organization from webknossos.users_ u where u._id = '$requestingUserId')" - def parse(r: ScriptsRow): Fox[Script] = + protected def parse(r: ScriptsRow): Fox[Script] = Fox.successful( Script( ObjectId(r._Id), diff --git a/app/models/task/Task.scala b/app/models/task/Task.scala index cf956dfe480..99aad97d2c1 100755 --- a/app/models/task/Task.scala +++ b/app/models/task/Task.scala @@ -36,12 +36,12 @@ case class Task( class TaskDAO @Inject()(sqlClient: SQLClient, projectDAO: ProjectDAO)(implicit ec: ExecutionContext) extends SQLDAO[Task, TasksRow, Tasks](sqlClient) { - val collection = Tasks + protected val collection = Tasks - def idColumn(x: Tasks): profile.api.Rep[String] = x._Id - def isDeletedColumn(x: Tasks): profile.api.Rep[Boolean] = x.isdeleted + protected def idColumn(x: Tasks): profile.api.Rep[String] = x._Id + protected def isDeletedColumn(x: Tasks): profile.api.Rep[Boolean] = x.isdeleted - def parse(r: TasksRow): Fox[Task] = + protected def parse(r: TasksRow): Fox[Task] = for { editPosition <- Vec3Int.fromList(parseArrayTuple(r.editposition).map(_.toInt)) ?~> "could not parse edit position" editRotation <- Vec3Double.fromList(parseArrayTuple(r.editrotation).map(_.toDouble)) ?~> "could not parse edit rotation" @@ -64,11 +64,11 @@ class TaskDAO @Inject()(sqlClient: SQLClient, projectDAO: ProjectDAO)(implicit e ) } - override def readAccessQ(requestingUserId: ObjectId) = + override protected def readAccessQ(requestingUserId: ObjectId) = s"""((select _team from webknossos.projects p where _project = p._id) in (select _team from webknossos.user_team_roles where _user = '${requestingUserId.id}') or ((select _organization from webknossos.teams where webknossos.teams._id = (select _team from webknossos.projects p where _project = p._id)) in (select _organization from webknossos.users_ where _id = '${requestingUserId.id}' and isAdmin)))""" - override def deleteAccessQ(requestingUserId: ObjectId) = + override protected def deleteAccessQ(requestingUserId: ObjectId) = s"""((select _team from webknossos.projects p where _project = p._id) in (select _team from webknossos.user_team_roles where isTeamManager and _user = '${requestingUserId.id}') or ((select _organization from webknossos.teams where webknossos.teams._id = (select _team from webknossos.projects p where _project = p._id)) in (select _organization from webknossos.users_ where _id = '${requestingUserId.id}' and isAdmin)))""" diff --git a/app/models/task/TaskCreationService.scala b/app/models/task/TaskCreationService.scala index 379b977a2bc..39072b2cde2 100644 --- a/app/models/task/TaskCreationService.scala +++ b/app/models/task/TaskCreationService.scala @@ -16,7 +16,7 @@ import models.annotation._ import models.binary.{DataSet, DataSetDAO, DataSetService} import models.project.{Project, ProjectDAO} import models.team.{Team, TeamDAO, TeamService} -import models.user.{User, UserExperiencesDAO, UserService, UserTeamRolesDAO} +import models.user.{User, UserDAO, UserExperiencesDAO, UserService} import net.liftweb.common.{Box, Empty, Failure, Full} import oxalis.telemetry.SlackNotificationService import play.api.i18n.{Messages, MessagesProvider} @@ -33,7 +33,7 @@ class TaskCreationService @Inject()(taskTypeService: TaskTypeService, userService: UserService, teamDAO: TeamDAO, teamService: TeamService, - userTeamRolesDAO: UserTeamRolesDAO, + userDAO: UserDAO, slackNotificationService: SlackNotificationService, projectDAO: ProjectDAO, annotationDAO: AnnotationDAO, @@ -506,7 +506,7 @@ class TaskCreationService @Inject()(taskTypeService: TaskTypeService, if (dataSetTeams.isEmpty) Fox.successful(Some(subteamId)) else { for { - memberDifference <- userTeamRolesDAO.findMemberDifference(subteamId, dataSetTeams) + memberDifference <- userDAO.findTeamMemberDifference(subteamId, dataSetTeams) } yield if (memberDifference.isEmpty) None else Some(subteamId) } diff --git a/app/models/task/TaskType.scala b/app/models/task/TaskType.scala index e8428f8f503..bda72f3a50a 100755 --- a/app/models/task/TaskType.scala +++ b/app/models/task/TaskType.scala @@ -78,12 +78,12 @@ class TaskTypeService @Inject()(teamDAO: TeamDAO, taskTypeDAO: TaskTypeDAO)(impl class TaskTypeDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[TaskType, TasktypesRow, Tasktypes](sqlClient) { - val collection = Tasktypes + protected val collection = Tasktypes - def idColumn(x: Tasktypes): Rep[String] = x._Id - def isDeletedColumn(x: Tasktypes): Rep[Boolean] = x.isdeleted + protected def idColumn(x: Tasktypes): Rep[String] = x._Id + protected def isDeletedColumn(x: Tasktypes): Rep[Boolean] = x.isdeleted - def parse(r: TasktypesRow): Fox[TaskType] = + protected def parse(r: TasktypesRow): Fox[TaskType] = for { tracingType <- TracingType.fromString(r.tracingtype) ?~> "failed to parse tracing type" settingsAllowedModes <- Fox.combined(parseArrayTuple(r.settingsAllowedmodes).map(TracingMode.fromString(_).toFox)) ?~> "failed to parse tracing mode" @@ -108,11 +108,11 @@ class TaskTypeDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) r.isdeleted ) - override def readAccessQ(requestingUserId: ObjectId) = + override protected def readAccessQ(requestingUserId: ObjectId) = s"""(_team in (select _team from webknossos.user_team_roles where _user = '${requestingUserId.id}') or _organization = (select _organization from webknossos.users_ where _id = '${requestingUserId.id}' and isAdmin))""" - override def updateAccessQ(requestingUserId: ObjectId) = + override protected def updateAccessQ(requestingUserId: ObjectId) = s"""(_team in (select _team from webknossos.user_team_roles where isTeamManager and _user = '${requestingUserId.id}') or _organization = (select _organization from webknossos.users_ where _id = '${requestingUserId.id}' and isAdmin))""" diff --git a/app/models/team/Team.scala b/app/models/team/Team.scala index e7ac3cb1652..0ddd1825d51 100755 --- a/app/models/team/Team.scala +++ b/app/models/team/Team.scala @@ -98,12 +98,12 @@ class TeamService @Inject()(organizationDAO: OrganizationDAO, class TeamDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[Team, TeamsRow, Teams](sqlClient) { - val collection = Teams + protected val collection = Teams - def idColumn(x: Teams): Rep[String] = x._Id - def isDeletedColumn(x: Teams): Rep[Boolean] = x.isdeleted + protected def idColumn(x: Teams): Rep[String] = x._Id + protected def isDeletedColumn(x: Teams): Rep[Boolean] = x.isdeleted - def parse(r: TeamsRow): Fox[Team] = + protected def parse(r: TeamsRow): Fox[Team] = Fox.successful( Team( ObjectId(r._Id), @@ -114,11 +114,11 @@ class TeamDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) r.isdeleted )) - override def readAccessQ(requestingUserId: ObjectId) = + override protected def readAccessQ(requestingUserId: ObjectId) = s"""(_id in (select _team from webknossos.user_team_roles where _user = '$requestingUserId') or _organization in (select _organization from webknossos.users_ where _id = '$requestingUserId' and isAdmin))""" - override def deleteAccessQ(requestingUserId: ObjectId) = + override protected def deleteAccessQ(requestingUserId: ObjectId) = s"""(not isorganizationteam and _organization in (select _organization from webknossos.users_ where _id = '$requestingUserId' and isAdmin))""" diff --git a/app/models/user/Invite.scala b/app/models/user/Invite.scala index e4ba58f0d04..0a61c35aabf 100644 --- a/app/models/user/Invite.scala +++ b/app/models/user/Invite.scala @@ -84,13 +84,13 @@ class InviteService @Inject()(conf: WkConf, class InviteDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[Invite, InvitesRow, Invites](sqlClient) { - val collection = Invites + protected val collection = Invites - def idColumn(x: Invites): Rep[String] = x._Id + protected def idColumn(x: Invites): Rep[String] = x._Id - def isDeletedColumn(x: Invites): Rep[Boolean] = x.isdeleted + protected def isDeletedColumn(x: Invites): Rep[Boolean] = x.isdeleted - def parse(r: InvitesRow): Fox[Invite] = + protected def parse(r: InvitesRow): Fox[Invite] = Fox.successful( Invite( ObjectId(r._Id), diff --git a/app/models/user/MultiUser.scala b/app/models/user/MultiUser.scala index fef6fad8c66..f04c2c726ff 100644 --- a/app/models/user/MultiUser.scala +++ b/app/models/user/MultiUser.scala @@ -30,12 +30,12 @@ case class MultiUser( class MultiUserDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[MultiUser, MultiusersRow, Multiusers](sqlClient) { - val collection = Multiusers + protected val collection = Multiusers - def idColumn(x: Multiusers): Rep[String] = x._Id - def isDeletedColumn(x: Multiusers): Rep[Boolean] = x.isdeleted + protected def idColumn(x: Multiusers): Rep[String] = x._Id + protected def isDeletedColumn(x: Multiusers): Rep[Boolean] = x.isdeleted - def parse(r: MultiusersRow): Fox[MultiUser] = + protected def parse(r: MultiusersRow): Fox[MultiUser] = for { novelUserExperienceInfos <- JsonHelper.parseAndValidateJson[JsObject](r.noveluserexperienceinfos).toFox theme <- Theme.fromString(r.selectedtheme).toFox diff --git a/app/models/user/User.scala b/app/models/user/User.scala index 50f2e5ae9d9..76d2878a06f 100755 --- a/app/models/user/User.scala +++ b/app/models/user/User.scala @@ -60,12 +60,12 @@ case class User( class UserDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[User, UsersRow, Users](sqlClient) { - val collection = Users + protected val collection = Users - def idColumn(x: Users): Rep[String] = x._Id - def isDeletedColumn(x: Users): Rep[Boolean] = x.isdeleted + protected def idColumn(x: Users): Rep[String] = x._Id + protected def isDeletedColumn(x: Users): Rep[Boolean] = x.isdeleted - def parse(r: UsersRow): Fox[User] = + protected def parse(r: UsersRow): Fox[User] = for { userConfiguration <- parseAndValidateJson[JsObject](r.userconfiguration) } yield { @@ -88,14 +88,14 @@ class UserDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) ) } - override def readAccessQ(requestingUserId: ObjectId) = + override protected def readAccessQ(requestingUserId: ObjectId) = s"""(_id in (select _user from webknossos.user_team_roles where _team in (select _team from webknossos.user_team_roles where _user = '$requestingUserId' and isTeamManager))) or (_organization in (select _organization from webknossos.users_ where _id = '$requestingUserId' and isAdmin)) or _id = '$requestingUserId'""" - override def deleteAccessQ(requestingUserId: ObjectId) = + override protected def deleteAccessQ(requestingUserId: ObjectId) = s"_organization in (select _organization from webknossos.users_ where _id = '$requestingUserId' and isAdmin)" - def listAccessQ(requestingUserId: ObjectId) = + private def listAccessQ(requestingUserId: ObjectId) = s"""(${readAccessQ(requestingUserId)}) and ( @@ -262,11 +262,6 @@ class UserDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) _ <- run(sqlu"""update webknossos.users set isDeleted = true where _organization = $organizationId""") } yield () -} - -class UserTeamRolesDAO @Inject()(userDAO: UserDAO, sqlClient: SQLClient)(implicit ec: ExecutionContext) - extends SimpleSQLDAO(sqlClient) { - def findTeamMembershipsForUser(userId: ObjectId): Fox[List[TeamMembership]] = { val query = for { (teamRoleRow, team) <- UserTeamRoles.filter(_._User === userId.id) join Teams on (_._Team === _._Id) @@ -281,23 +276,23 @@ class UserTeamRolesDAO @Inject()(userDAO: UserDAO, sqlClient: SQLClient)(implici } yield teamMemberships } - private def insertQuery(userId: ObjectId, teamMembership: TeamMembership) = + private def insertTeamMembershipQuery(userId: ObjectId, teamMembership: TeamMembership) = sqlu"insert into webknossos.user_team_roles(_user, _team, isTeamManager) values($userId, ${teamMembership.teamId}, ${teamMembership.isTeamManager})" def updateTeamMembershipsForUser(userId: ObjectId, teamMemberships: List[TeamMembership])( implicit ctx: DBAccessContext): Fox[Unit] = { val clearQuery = sqlu"delete from webknossos.user_team_roles where _user = $userId" - val insertQueries = teamMemberships.map(insertQuery(userId, _)) + val insertQueries = teamMemberships.map(insertTeamMembershipQuery(userId, _)) for { - _ <- userDAO.assertUpdateAccess(userId) + _ <- assertUpdateAccess(userId) _ <- run(DBIO.sequence(List(clearQuery) ++ insertQueries).transactionally) } yield () } def insertTeamMembership(userId: ObjectId, teamMembership: TeamMembership)(implicit ctx: DBAccessContext): Fox[Unit] = for { - _ <- userDAO.assertUpdateAccess(userId) - _ <- run(insertQuery(userId, teamMembership)) + _ <- assertUpdateAccess(userId) + _ <- run(insertTeamMembershipQuery(userId, teamMembership)) } yield () def removeTeamFromAllUsers(teamId: ObjectId): Fox[Unit] = @@ -305,9 +300,9 @@ class UserTeamRolesDAO @Inject()(userDAO: UserDAO, sqlClient: SQLClient)(implici _ <- run(sqlu"delete from webknossos.user_team_roles where _team = $teamId") } yield () - def findMemberDifference(potentialSubteam: ObjectId, superteams: List[ObjectId]): Fox[List[User]] = + def findTeamMemberDifference(potentialSubteam: ObjectId, superteams: List[ObjectId]): Fox[List[User]] = for { - r <- run(sql"""select #${userDAO.columnsWithPrefix("u.")} from webknossos.users_ u + r <- run(sql"""select #${columnsWithPrefix("u.")} from webknossos.users_ u join webknossos.user_team_roles tr on u._id = tr._user where not u.isAdmin and not u.isDeactivated @@ -316,8 +311,9 @@ class UserTeamRolesDAO @Inject()(userDAO: UserDAO, sqlClient: SQLClient)(implici (select _user from webknossos.user_team_roles where _team in #${writeStructTupleWithQuotes(superteams.map(_.id))}) """.as[UsersRow]) - parsed <- Fox.combined(r.toList.map(userDAO.parse)) + parsed <- Fox.combined(r.toList.map(parse)) } yield parsed + } class UserExperiencesDAO @Inject()(sqlClient: SQLClient, userDAO: UserDAO)(implicit ec: ExecutionContext) diff --git a/app/models/user/UserService.scala b/app/models/user/UserService.scala index f6f50d23247..6f25c2e1ee7 100755 --- a/app/models/user/UserService.scala +++ b/app/models/user/UserService.scala @@ -29,7 +29,6 @@ import scala.concurrent.{ExecutionContext, Future} class UserService @Inject()(conf: WkConf, userDAO: UserDAO, multiUserDAO: MultiUserDAO, - userTeamRolesDAO: UserTeamRolesDAO, userExperiencesDAO: UserExperiencesDAO, userDataSetConfigurationDAO: UserDataSetConfigurationDAO, userDataSetLayerConfigurationDAO: UserDataSetLayerConfigurationDAO, @@ -115,7 +114,7 @@ class UserService @Inject()(conf: WkConf, lastTaskTypeId = None ) _ <- userDAO.insertOne(user) - _ <- Fox.combined(teamMemberships.map(userTeamRolesDAO.insertTeamMembership(user._id, _))) + _ <- Fox.combined(teamMemberships.map(userDAO.insertTeamMembership(user._id, _))) } yield user } @@ -153,7 +152,7 @@ class UserService @Inject()(conf: WkConf, created = Instant.now ) _ <- userDAO.insertOne(user) - _ <- Fox.combined(teamMemberships.map(userTeamRolesDAO.insertTeamMembership(user._id, _))) + _ <- Fox.combined(teamMemberships.map(userDAO.insertTeamMembership(user._id, _))) _ = logger.info( s"Multiuser ${originalUser._multiUser} joined organization $organizationId with new user id $newUserId.") } yield user @@ -187,7 +186,7 @@ class UserService @Inject()(conf: WkConf, isDatasetManager, isDeactivated = !activated, lastTaskTypeId) - _ <- userTeamRolesDAO.updateTeamMembershipsForUser(user._id, teamMemberships) + _ <- userDAO.updateTeamMembershipsForUser(user._id, teamMemberships) _ <- userExperiencesDAO.updateExperiencesForUser(user, experiences) _ = userCache.invalidateUser(user._id) _ <- if (oldEmail == email) Fox.successful(()) else tokenDAO.updateEmail(oldEmail, email) @@ -257,7 +256,7 @@ class UserService @Inject()(conf: WkConf, userExperiencesDAO.findAllExperiencesForUser(_user) def teamMembershipsFor(_user: ObjectId): Fox[List[TeamMembership]] = - userTeamRolesDAO.findTeamMembershipsForUser(_user) + userDAO.findTeamMembershipsForUser(_user) def teamManagerMembershipsFor(_user: ObjectId): Fox[List[TeamMembership]] = for { diff --git a/app/models/user/time/TimeSpan.scala b/app/models/user/time/TimeSpan.scala index f93c23502d1..563ad876aaa 100755 --- a/app/models/user/time/TimeSpan.scala +++ b/app/models/user/time/TimeSpan.scala @@ -47,12 +47,12 @@ object TimeSpan { class TimeSpanDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[TimeSpan, TimespansRow, Timespans](sqlClient) { - val collection = Timespans + protected val collection = Timespans - def idColumn(x: Timespans): Rep[String] = x._Id - def isDeletedColumn(x: Timespans): Rep[Boolean] = x.isdeleted + protected def idColumn(x: Timespans): Rep[String] = x._Id + protected def isDeletedColumn(x: Timespans): Rep[Boolean] = x.isdeleted - def parse(r: TimespansRow): Fox[TimeSpan] = + protected def parse(r: TimespansRow): Fox[TimeSpan] = Fox.successful( TimeSpan( ObjectId(r._Id), diff --git a/app/oxalis/security/Token.scala b/app/oxalis/security/Token.scala index 53dc80284c0..de9c44a53a1 100644 --- a/app/oxalis/security/Token.scala +++ b/app/oxalis/security/Token.scala @@ -54,12 +54,12 @@ object Token { class TokenDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SQLDAO[Token, TokensRow, Tokens](sqlClient) { - val collection = Tokens + protected val collection = Tokens - def idColumn(x: Tokens): Rep[String] = x._Id - def isDeletedColumn(x: Tokens): Rep[Boolean] = x.isdeleted + protected def idColumn(x: Tokens): Rep[String] = x._Id + protected def isDeletedColumn(x: Tokens): Rep[Boolean] = x.isdeleted - def parse(r: TokensRow): Fox[Token] = + protected def parse(r: TokensRow): Fox[Token] = for { tokenType <- TokenType.fromString(r.tokentype).toFox } yield { diff --git a/app/utils/SQLHelpers.scala b/app/utils/SQLHelpers.scala index 48604d74f38..5c1f9cb8e70 100644 --- a/app/utils/SQLHelpers.scala +++ b/app/utils/SQLHelpers.scala @@ -25,31 +25,31 @@ class SQLClient @Inject()(configuration: Configuration, slackNotificationService } trait SQLTypeImplicits { - implicit object SetObjectId extends SetParameter[ObjectId] { + implicit protected object SetObjectId extends SetParameter[ObjectId] { def apply(v: ObjectId, pp: PositionedParameters): Unit = pp.setString(v.id) } - implicit object SetObjectIdOpt extends SetParameter[Option[ObjectId]] { + implicit protected object SetObjectIdOpt extends SetParameter[Option[ObjectId]] { def apply(v: Option[ObjectId], pp: PositionedParameters): Unit = pp.setStringOption(v.map(_.id)) } - implicit object GetObjectId extends GetResult[ObjectId] { + implicit protected object GetObjectId extends GetResult[ObjectId] { override def apply(v1: PositionedResult): ObjectId = ObjectId(v1.<<) } - implicit object SetInstant extends SetParameter[Instant] { + implicit protected object SetInstant extends SetParameter[Instant] { def apply(v: Instant, pp: PositionedParameters): Unit = pp.setTimestamp(v.toSql) } - implicit object SetInstantOpt extends SetParameter[Option[Instant]] { + implicit protected object SetInstantOpt extends SetParameter[Option[Instant]] { def apply(v: Option[Instant], pp: PositionedParameters): Unit = pp.setTimestampOption(v.map(_.toSql)) } - implicit object GetInstant extends GetResult[Instant] { + implicit protected object GetInstant extends GetResult[Instant] { override def apply(v1: PositionedResult): Instant = Instant.fromSql(v1.<<) } - implicit object GetInstantOpt extends GetResult[Option[Instant]] { + implicit protected object GetInstantOpt extends GetResult[Option[Instant]] { override def apply(v1: PositionedResult): Option[Instant] = v1.nextTimestampOption().map(Instant.fromSql) } } @@ -59,11 +59,11 @@ class SimpleSQLDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext with LazyLogging with SQLTypeImplicits { - lazy val transactionSerializationError = "could not serialize access" + protected lazy val transactionSerializationError = "could not serialize access" - def run[R](query: DBIOAction[R, NoStream, Nothing], - retryCount: Int = 0, - retryIfErrorContains: List[String] = List()): Fox[R] = { + protected def run[R](query: DBIOAction[R, NoStream, Nothing], + retryCount: Int = 0, + retryIfErrorContains: List[String] = List()): Fox[R] = { val foxFuture = sqlClient.db.run(query.asTry).map { result: Try[R] => result match { case Success(res) => @@ -96,22 +96,22 @@ class SimpleSQLDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext s"Causing query: ${query.getDumpInfo.mainInfo}" ) - def writeArrayTuple(elements: List[String]): String = { + protected def writeArrayTuple(elements: List[String]): String = { val commaSeparated = elements.map(sanitizeInArrayTuple).map(e => s""""$e"""").mkString(",") s"{$commaSeparated}" } - def writeStructTuple(elements: List[String]): String = { + protected def writeStructTuple(elements: List[String]): String = { val commaSeparated = elements.mkString(",") s"($commaSeparated)" } - def writeStructTupleWithQuotes(elements: List[String]): String = { + protected def writeStructTupleWithQuotes(elements: List[String]): String = { val commaSeparated = elements.map(e => s"'$e'").mkString(",") s"($commaSeparated)" } - def parseArrayTuple(literal: String): List[String] = { + protected def parseArrayTuple(literal: String): List[String] = { val trimmed = literal.drop(1).dropRight(1) if (trimmed.isEmpty) List.empty @@ -125,7 +125,7 @@ class SimpleSQLDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext } } - def escapeLiteral(aString: String): String = { + protected def escapeLiteral(aString: String): String = { // Ported from PostgreSQL 9.2.4 source code in src/interfaces/libpq/fe-exec.c var hasBackslash = false val escaped = new StringBuffer("'") @@ -149,38 +149,38 @@ class SimpleSQLDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext } } - def writeEscapedTuple(seq: List[String]): String = + protected def writeEscapedTuple(seq: List[String]): String = "(" + seq.map(escapeLiteral).mkString(", ") + ")" - def sanitize(aString: String): String = aString.replaceAll("'", "") + protected def sanitize(aString: String): String = aString.replaceAll("'", "") // escape ' by doubling it, escape " with backslash, drop commas - def sanitizeInArrayTuple(aString: String): String = + protected def sanitizeInArrayTuple(aString: String): String = aString.replaceAll("'", """''""").replaceAll(""""""", """\\"""").replaceAll(""",""", "") - def desanitizeFromArrayTuple(aString: String): String = + protected def desanitizeFromArrayTuple(aString: String): String = aString.replaceAll("""\\"""", """"""").replaceAll("""\\,""", ",") - def optionLiteral(aStringOpt: Option[String]): String = aStringOpt match { + protected def optionLiteral(aStringOpt: Option[String]): String = aStringOpt match { case Some(aString) => "'" + aString + "'" case None => "null" } - def optionLiteralSanitized(aStringOpt: Option[String]): String = optionLiteral(aStringOpt.map(sanitize)) + protected def optionLiteralSanitized(aStringOpt: Option[String]): String = optionLiteral(aStringOpt.map(sanitize)) } abstract class SecuredSQLDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SimpleSQLDAO(sqlClient) { - def collectionName: String - def existingCollectionName: String = collectionName + "_" + protected def collectionName: String + protected def existingCollectionName: String = collectionName + "_" - def anonymousReadAccessQ(sharingToken: Option[String]): String = "false" - def readAccessQ(requestingUserId: ObjectId): String = "true" - def updateAccessQ(requestingUserId: ObjectId): String = readAccessQ(requestingUserId) - def deleteAccessQ(requestingUserId: ObjectId): String = readAccessQ(requestingUserId) + protected def anonymousReadAccessQ(sharingToken: Option[String]): String = "false" + protected def readAccessQ(requestingUserId: ObjectId): String = "true" + protected def updateAccessQ(requestingUserId: ObjectId): String = readAccessQ(requestingUserId) + protected def deleteAccessQ(requestingUserId: ObjectId): String = readAccessQ(requestingUserId) - def readAccessQuery(implicit ctx: DBAccessContext): Fox[String] = + protected def readAccessQuery(implicit ctx: DBAccessContext): Fox[String] = if (ctx.globalAccess) Fox.successful("true") else { for { @@ -217,7 +217,7 @@ abstract class SecuredSQLDAO @Inject()(sqlClient: SQLClient)(implicit ec: Execut } yield () } - def userIdFromCtx(implicit ctx: DBAccessContext): Fox[ObjectId] = + protected def userIdFromCtx(implicit ctx: DBAccessContext): Fox[ObjectId] = ctx.data match { case Some(user: User) => Fox.successful(user._id) case Some(userSharingTokenContainer: UserSharingTokenContainer) => @@ -225,7 +225,7 @@ abstract class SecuredSQLDAO @Inject()(sqlClient: SQLClient)(implicit ec: Execut case _ => Fox.failure("Access denied.") } - def accessQueryFromAccessQWithPrefix(accessQ: (ObjectId, String) => String, prefix: String)( + protected def accessQueryFromAccessQWithPrefix(accessQ: (ObjectId, String) => String, prefix: String)( implicit ctx: DBAccessContext): Fox[String] = if (ctx.globalAccess) Fox.successful("true") else { @@ -239,7 +239,7 @@ abstract class SecuredSQLDAO @Inject()(sqlClient: SQLClient)(implicit ec: Execut } } - def accessQueryFromAccessQ(accessQ: ObjectId => String)(implicit ctx: DBAccessContext): Fox[String] = + protected def accessQueryFromAccessQ(accessQ: ObjectId => String)(implicit ctx: DBAccessContext): Fox[String] = if (ctx.globalAccess) Fox.successful("true") else { for { @@ -271,31 +271,31 @@ abstract class SecuredSQLDAO @Inject()(sqlClient: SQLClient)(implicit ec: Execut abstract class SQLDAO[C, R, X <: AbstractTable[R]] @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) extends SecuredSQLDAO(sqlClient) { - def collection: TableQuery[X] - def collectionName: String = + protected def collection: TableQuery[X] + protected def collectionName: String = collection.shaped.value.schemaName.map(_ + ".").getOrElse("") + collection.shaped.value.tableName - def columnsList: List[String] = collection.baseTableRow.create_*.map(_.name).toList + protected def columnsList: List[String] = collection.baseTableRow.create_*.map(_.name).toList def columns: String = columnsList.mkString(", ") def columnsWithPrefix(prefix: String): String = columnsList.map(prefix + _).mkString(", ") - def idColumn(x: X): Rep[String] - def isDeletedColumn(x: X): Rep[Boolean] + protected def idColumn(x: X): Rep[String] + protected def isDeletedColumn(x: X): Rep[Boolean] - def notdel(r: X): Rep[Boolean] = isDeletedColumn(r) === false + protected def notdel(r: X): Rep[Boolean] = isDeletedColumn(r) === false - def parse(row: X#TableElementType): Fox[C] + protected def parse(row: X#TableElementType): Fox[C] - def parseFirst(rowSeq: Seq[X#TableElementType], queryLabel: ObjectId): Fox[C] = + protected def parseFirst(rowSeq: Seq[X#TableElementType], queryLabel: ObjectId): Fox[C] = parseFirst(rowSeq, queryLabel.toString) - def parseFirst(rowSeq: Seq[X#TableElementType], queryLabel: String): Fox[C] = + protected def parseFirst(rowSeq: Seq[X#TableElementType], queryLabel: String): Fox[C] = for { firstRow <- rowSeq.headOption.toFox // No error chain here, as this should stay Fox.Empty parsed <- parse(firstRow) ?~> s"Parsing failed for row in $collectionName queried by $queryLabel" } yield parsed - def parseAll(rowSeq: Seq[X#TableElementType]): Fox[List[C]] = + protected def parseAll(rowSeq: Seq[X#TableElementType]): Fox[List[C]] = Fox.combined(rowSeq.toList.map(parse)) ?~> s"Parsing failed for a row in $collectionName during list query" @nowarn // suppress warning about unused implicit ctx, as it is used in subclasses @@ -322,7 +322,7 @@ abstract class SQLDAO[C, R, X <: AbstractTable[R]] @Inject()(sqlClient: SQLClien } yield () } - def updateStringCol(id: ObjectId, column: X => Rep[String], newValue: String)( + protected def updateStringCol(id: ObjectId, column: X => Rep[String], newValue: String)( implicit ctx: DBAccessContext): Fox[Unit] = { val q = for { row <- collection if notdel(row) && idColumn(row) === id.id } yield column(row) for { @@ -331,11 +331,11 @@ abstract class SQLDAO[C, R, X <: AbstractTable[R]] @Inject()(sqlClient: SQLClien } yield () } - def updateObjectIdCol(id: ObjectId, column: X => Rep[String], newValue: ObjectId)( + protected def updateObjectIdCol(id: ObjectId, column: X => Rep[String], newValue: ObjectId)( implicit ctx: DBAccessContext): Fox[Unit] = updateStringCol(id, column, newValue.id) - def updateBooleanCol(id: ObjectId, column: X => Rep[Boolean], newValue: Boolean)( + protected def updateBooleanCol(id: ObjectId, column: X => Rep[Boolean], newValue: Boolean)( implicit ctx: DBAccessContext): Fox[Unit] = { val q = for { row <- collection if notdel(row) && idColumn(row) === id.id } yield column(row) for { @@ -344,7 +344,7 @@ abstract class SQLDAO[C, R, X <: AbstractTable[R]] @Inject()(sqlClient: SQLClien } yield () } - def updateTimestampCol(id: ObjectId, column: X => Rep[java.sql.Timestamp], newValue: Instant)( + protected def updateTimestampCol(id: ObjectId, column: X => Rep[java.sql.Timestamp], newValue: Instant)( implicit ctx: DBAccessContext): Fox[Unit] = { val q = for { row <- collection if notdel(row) && idColumn(row) === id.id } yield column(row) for { From b0131496d7a632acd758bb85d21429cfde693d42 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 16 Dec 2022 14:02:20 +0100 Subject: [PATCH 2/5] temporarily disable vx related polling (#6702) --- frontend/javascripts/admin/voxelytics/workflow_view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/javascripts/admin/voxelytics/workflow_view.tsx b/frontend/javascripts/admin/voxelytics/workflow_view.tsx index 1fea0c1ef24..7ab56b425db 100644 --- a/frontend/javascripts/admin/voxelytics/workflow_view.tsx +++ b/frontend/javascripts/admin/voxelytics/workflow_view.tsx @@ -22,7 +22,7 @@ import { getVoxelyticsWorkflow, isWorkflowAccessibleBySwitching } from "admin/ad import BrainSpinner, { BrainSpinnerWithError } from "components/brain_spinner"; import TaskListView from "./task_list_view"; -export const VX_POLLING_INTERVAL = 30 * 1000; // 30s +export const VX_POLLING_INTERVAL = null; // disabled for now. 30 * 1000; // 30s type LoadingState = | { status: "PENDING" } From d2314b7bfb6493128852ea11424e084ce1d3163a Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 19 Dec 2022 09:42:04 +0100 Subject: [PATCH 3/5] Fix crash in publication page and add error boundaries (#6700) * implement error boundary for non-tracing-view routes * also add router-independent error boundary (e.g., in case something crashes in the navbar) * improve wording of error boundary * don't crash publications page if a publication doesn't have any active datasets or annotations * fix typing * update changelog * Apply suggestions from code review Co-authored-by: Norman Rzepka * improve typing Co-authored-by: Norman Rzepka --- CHANGELOG.unreleased.md | 2 + .../javascripts/components/error_boundary.tsx | 51 ++++++ .../javascripts/components/secured_route.tsx | 6 +- .../dashboard/publication_card.tsx | 106 ++++++++----- frontend/javascripts/libs/error_handling.ts | 10 +- frontend/javascripts/main.tsx | 21 +-- frontend/javascripts/router.tsx | 149 +++++++++++------- 7 files changed, 233 insertions(+), 112 deletions(-) create mode 100644 frontend/javascripts/components/error_boundary.tsx diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index e798561c7e6..529b93ca696 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -19,6 +19,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Changed - webKnossos is now able to recover from a lost webGL context. [#6663](https://github.com/scalableminds/webknossos/pull/6663) - Bulk task creation now needs the taskTypeId, the task type summary will no longer be accepted. [#6640](https://github.com/scalableminds/webknossos/pull/6640) +- Error handling and reporting is more robust now. [#6700](https://github.com/scalableminds/webknossos/pull/6700) ### Fixed - Fixed import of N5 datasets. [#6668](https://github.com/scalableminds/webknossos/pull/6668) @@ -29,6 +30,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Fixed access of remote datasets using the Amazon S3 protocol [#6679](https://github.com/scalableminds/webknossos/pull/6679) - Fixed a bug in line measurement that would lead to an infinite loop. [#6689](https://github.com/scalableminds/webknossos/pull/6689) - Fixed a bug where malformed json files could lead to uncaught exceptions.[#6691](https://github.com/scalableminds/webknossos/pull/6691) +- Fixed rare crash in publications page. [#6700](https://github.com/scalableminds/webknossos/pull/6700) - Respect the config value mail.smtp.auth (used to be ignored, always using true) [#6692](https://github.com/scalableminds/webknossos/pull/6692) ### Removed diff --git a/frontend/javascripts/components/error_boundary.tsx b/frontend/javascripts/components/error_boundary.tsx new file mode 100644 index 00000000000..b74b6d8dd26 --- /dev/null +++ b/frontend/javascripts/components/error_boundary.tsx @@ -0,0 +1,51 @@ +import { Alert } from "antd"; +import ErrorHandling from "libs/error_handling"; +import React, { ErrorInfo } from "react"; + +export default class ErrorBoundary extends React.Component< + {}, + { error?: Error | null; info?: ErrorInfo | null } +> { + constructor(props: {}) { + super(props); + this.state = {}; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + this.setState({ error, info }); + ErrorHandling.notify(error, { info }); + } + + render() { + if (this.state.error != null) { + const { error, info } = this.state; + const componentStack = info?.componentStack; + const errorMessage = (error || "").toString(); + const errorDescription = componentStack; + + return ( +
+

webKnossos encountered an error

+ +

+ Please try reloading the page. The error has been reported to our system and will be + investigated. If the error persists and/or you need help as soon as possible, feel free + to{" "} + + contact us. + +

+ + {errorDescription}} + /> +
+ ); + } + + return this.props.children; + } +} diff --git a/frontend/javascripts/components/secured_route.tsx b/frontend/javascripts/components/secured_route.tsx index 73b0390f70c..3961b6cccb0 100644 --- a/frontend/javascripts/components/secured_route.tsx +++ b/frontend/javascripts/components/secured_route.tsx @@ -4,7 +4,7 @@ import type { ComponentType } from "react"; import React from "react"; import LoginView from "admin/auth/login_view"; -type Props = RouteComponentProps & { +export type SecuredRouteProps = RouteComponentProps & { component?: ComponentType; path: string; render?: (arg0: RouteComponentProps) => React.ReactNode; @@ -16,7 +16,7 @@ type State = { isAdditionallyAuthenticated: boolean; }; -class SecuredRoute extends React.PureComponent { +class SecuredRoute extends React.PureComponent { state: State = { isAdditionallyAuthenticated: false, }; @@ -70,4 +70,4 @@ class SecuredRoute extends React.PureComponent { } } -export default withRouter(SecuredRoute); +export default withRouter(SecuredRoute); diff --git a/frontend/javascripts/dashboard/publication_card.tsx b/frontend/javascripts/dashboard/publication_card.tsx index 9133e758213..d476afaa1f0 100644 --- a/frontend/javascripts/dashboard/publication_card.tsx +++ b/frontend/javascripts/dashboard/publication_card.tsx @@ -197,14 +197,8 @@ function PublicationCard({ publication, showDetailedLink }: Props) { ), ]; sortedItems.sort(compareBy([] as Array, (item) => item.dataset.sortingKey)); - const [activeItem, setActiveItem] = useState(sortedItems[0]); - // This method will only be called for datasets with a publication, but Flow doesn't know that - if (publication == null) throw Error("Assertion Error: Dataset has no associated publication."); - const thumbnailURL = getThumbnailURL(activeItem.dataset); - const segmentationThumbnailURL = hasSegmentation(activeItem.dataset) - ? getSegmentationThumbnailURL(activeItem.dataset) - : null; - const details = getDetails(activeItem); + const [activeItem, setActiveItem] = useState(sortedItems[0]); + return ( -
+ +
+
+ ); +} + +function PublicationThumbnail({ + activeItem, + sortedItems, + setActiveItem, +}: { + activeItem: PublicationItem | null; + sortedItems: PublicationItem[]; + setActiveItem: React.Dispatch>; +}) { + if (activeItem == null) { + return
; + } + + const thumbnailURL = getThumbnailURL(activeItem.dataset); + const segmentationThumbnailURL = hasSegmentation(activeItem.dataset) + ? getSegmentationThumbnailURL(activeItem.dataset) + : null; + const details = getDetails(activeItem); + + return ( +
+
+ +
Click To View
+ +
+ {segmentationThumbnailURL != null && (
- -
Click To View
- -
- {segmentationThumbnailURL != null && ( -
- )} - - {sortedItems.length > 1 && ( - - )} -
-
+ /> + )} + + {sortedItems.length > 1 && ( + + )}
- +
); } diff --git a/frontend/javascripts/libs/error_handling.ts b/frontend/javascripts/libs/error_handling.ts index 1f9734b97b8..1201d0ab7b8 100644 --- a/frontend/javascripts/libs/error_handling.ts +++ b/frontend/javascripts/libs/error_handling.ts @@ -139,8 +139,14 @@ class ErrorHandling { }); }); - // @ts-expect-error ts-migrate(2339) FIXME: Property 'onerror' does not exist on type '(Window... Remove this comment to see the full error message - window.onerror = (message: string, file: string, line: number, colno: number, error: Error) => { + window.onerror = ( + message: Event | string, + _file?: string, + _line?: number, + _colno?: number, + error?: Error, + ) => { + message = message.toString(); if (BLACKLISTED_ERROR_MESSAGES.indexOf(message) > -1) { console.warn("Ignoring", message); return; diff --git a/frontend/javascripts/main.tsx b/frontend/javascripts/main.tsx index 2ed3e53c79e..0a14cf7d532 100644 --- a/frontend/javascripts/main.tsx +++ b/frontend/javascripts/main.tsx @@ -17,6 +17,7 @@ import { persistQueryClient } from "@tanstack/react-query-persist-client"; import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import UserLocalStorage from "libs/user_local_storage"; import { compress, decompress } from "lz-string"; +import ErrorBoundary from "components/error_boundary"; const reactQueryClient = new QueryClient({ defaultOptions: { @@ -69,18 +70,20 @@ document.addEventListener("DOMContentLoaded", async () => { if (containerElement) { ReactDOM.render( - // @ts-ignore - - - {/* The DnDProvider is necessary for the TreeHierarchyView. Otherwise, the view may crash in + + {/* @ts-ignore */} + + + {/* The DnDProvider is necessary for the TreeHierarchyView. Otherwise, the view may crash in certain conditions. See https://github.com/scalableminds/webknossos/issues/5568 for context. The fix is inspired by: https://github.com/frontend-collective/react-sortable-tree/blob/9aeaf3d38b500d58e2bcc1d9b6febce12f8cc7b4/stories/barebones-no-context.js */} - - - - - , + + + + + + , containerElement, ); } diff --git a/frontend/javascripts/router.tsx b/frontend/javascripts/router.tsx index 0d3720ccf24..8916cd3096c 100644 --- a/frontend/javascripts/router.tsx +++ b/frontend/javascripts/router.tsx @@ -1,5 +1,5 @@ // @ts-expect-error ts-migrate(2305) FIXME: Module '"react-router-dom"' has no exported member... Remove this comment to see the full error message -import type { ContextRouter } from "react-router-dom"; +import type { ContextRouter, RouteProps } from "react-router-dom"; import { Redirect, Route, Router, Switch } from "react-router-dom"; import { Layout, Alert } from "antd"; import { connect } from "react-redux"; @@ -57,6 +57,8 @@ import window from "libs/window"; import { trackAction } from "oxalis/model/helpers/analytics"; import { coalesce } from "libs/utils"; import HelpButton from "oxalis/view/help_modal"; +import ErrorBoundary from "components/error_boundary"; + const { Content } = Layout; function loadable(loader: () => Promise<{ default: React.ComponentType<{}> }>) { @@ -117,6 +119,27 @@ function PageNotFoundView() { ); } +type GetComponentProps = T extends React.ComponentType | React.Component + ? P + : never; + +const RouteWithErrorBoundary: React.FC = (props) => { + return ( + + + + ); +}; + +const SecuredRouteWithErrorBoundary: React.FC> = (props) => { + return ( + // @ts-expect-error Accessing props.location works as intended. + + + + ); +}; + class ReactRouter extends React.Component { tracingView = ({ match }: ContextRouter) => { const initialMaybeCompoundType = @@ -186,7 +209,7 @@ class ReactRouter extends React.Component { - { @@ -201,7 +224,7 @@ class ReactRouter extends React.Component { return ; }} /> - { @@ -219,7 +242,7 @@ class ReactRouter extends React.Component { }} /> - { @@ -234,7 +257,7 @@ class ReactRouter extends React.Component { }} /> - { return null; }} /> - ( @@ -259,52 +282,52 @@ class ReactRouter extends React.Component { /> )} /> - - - - - - - - ( )} /> - ( @@ -315,7 +338,7 @@ class ReactRouter extends React.Component { /> )} /> - { ) => } exact /> - } /> - ( @@ -339,7 +362,7 @@ class ReactRouter extends React.Component { /> )} /> - ( @@ -370,12 +393,12 @@ class ReactRouter extends React.Component { render={this.tracingView} serverAuthenticationCallback={this.serverAuthenticationCallback} /> - } /> - ( @@ -392,7 +415,7 @@ class ReactRouter extends React.Component { /> )} /> - ( @@ -407,7 +430,7 @@ class ReactRouter extends React.Component { /> )} /> - { ) => } exact /> - - ( )} /> - ( @@ -438,37 +461,37 @@ class ReactRouter extends React.Component { /> )} /> - ( )} /> - } /> - ( )} /> - - } /> - ( @@ -476,30 +499,30 @@ class ReactRouter extends React.Component { )} /> - ( )} /> - } /> - - - } /> + } /> - ( { )} /> - } /> - } /> - } /> - } + /> + } + /> + } + /> + (isAuthenticated ? : )} /> - (isAuthenticated ? : )} /> - - + { const params = Utils.getUrlParamsObjectFromString(location.search); @@ -533,7 +568,7 @@ class ReactRouter extends React.Component { path="/datasets/:organizationName/:datasetName/view" render={this.tracingViewMode} /> - ( { /> )} /> - - ( @@ -616,27 +651,27 @@ class ReactRouter extends React.Component { path="/datasets/:organizationName/:datasetName" render={this.tracingViewMode} /> - ( )} /> - - - - - + + ( { /> )} /> - {!features().isDemoInstance && } - + {!features().isDemoInstance && ( + + )} + From 99ba7bc5255ec331a08d6312afd25f8720a70dc8 Mon Sep 17 00:00:00 2001 From: Florian M Date: Mon, 19 Dec 2022 11:18:12 +0100 Subject: [PATCH 4/5] Add Terms of Service Acceptance Concept (#6632) * [WIP] Add Terms of Service Acceptance Concept * add api for terms of service * add acceptanceNeeded route * enable changing the cookie signer secret in config, to prompt re-login * undo changes in application.conf * evolutions * adapt test db + snapshots * implement terms of service check * respect newlines in ToS content * add missing file * fix types * bump default ToS version to 1, so that enabling ToS is enough to activate it via application.conf * fix reversion schemaVersions, typo * show ToS in iframe * fix duplicate schema version * Add acceptanceDeadline for terms of service * fix merge, refresh snapshots * bump schema version number * format * reformat user insertOne sql query * adapt to new interface * allow to snooze the terms-of-services warning for a specific duration * fix tos url for iframe * format * incorporate pr feedback and change snooze time to 3 days * Update conf/application.conf Co-authored-by: Philipp Otto Co-authored-by: Philipp Otto --- MIGRATIONS.unreleased.md | 1 + .../AuthenticationController.scala | 12 +- app/controllers/InitialDataController.scala | 2 + app/controllers/OrganizationController.scala | 35 ++++ app/models/organization/Organization.scala | 32 +++- .../organization/OrganizationService.scala | 4 +- app/models/user/User.scala | 22 ++- app/models/user/UserService.scala | 5 +- .../CombinedAuthenticatorService.scala | 8 +- app/utils/WkConf.scala | 8 + conf/application.conf | 7 + conf/evolutions/093-terms-of-service.sql | 28 +++ conf/evolutions/reversions/090-cleanup.sql | 2 +- .../reversions/093-terms-of-service.sql | 28 +++ conf/messages | 2 + conf/webknossos.latest.routes | 3 + .../javascripts/admin/api/terms_of_service.ts | 23 +++ .../components/terms_of_services_check.tsx | 164 ++++++++++++++++++ frontend/javascripts/router.tsx | 65 +++---- .../javascripts/test/fixtures/dummy_user.ts | 1 + .../backend-snapshot-tests/misc.e2e.js.md | 1 + .../backend-snapshot-tests/misc.e2e.js.snap | Bin 1238 -> 1253 bytes .../backend-snapshot-tests/users.e2e.js.md | 12 ++ .../backend-snapshot-tests/users.e2e.js.snap | Bin 2797 -> 2837 bytes frontend/javascripts/types/api_flow_types.ts | 1 + test/db/organizations.csv | 6 +- test/db/users.csv | 12 +- tools/postgres/schema.sql | 5 +- .../util/tools/ConfigReader.scala | 10 ++ 29 files changed, 436 insertions(+), 63 deletions(-) create mode 100644 conf/evolutions/093-terms-of-service.sql create mode 100644 conf/evolutions/reversions/093-terms-of-service.sql create mode 100644 frontend/javascripts/admin/api/terms_of_service.ts create mode 100644 frontend/javascripts/components/terms_of_services_check.tsx diff --git a/MIGRATIONS.unreleased.md b/MIGRATIONS.unreleased.md index 3d0a151aae9..252ff9f3f44 100644 --- a/MIGRATIONS.unreleased.md +++ b/MIGRATIONS.unreleased.md @@ -14,3 +14,4 @@ User-facing changes are documented in the [changelog](CHANGELOG.released.md). - [091-folders.sql](conf/evolutions/091-folders.sql) - [092-oidc.sql](conf/evolutions/092-oidc.sql) +- [093-terms-of-service.sql](conf/evolutions/093-terms-of-service.sql) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index 4daa72a6717..559aa1003d4 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -129,7 +129,14 @@ class AuthenticationController @Inject()( val passwordInfo: PasswordInfo = password.map(passwordHasher.hash).getOrElse(userService.getOpenIdConnectPasswordInfo) for { - user <- userService.insert(organization._id, email, firstName, lastName, autoActivate, passwordInfo) ?~> "user.creation.failed" + user <- userService.insert(organization._id, + email, + firstName, + lastName, + autoActivate, + passwordInfo, + isAdmin = false, + isOrganizationOwner = false) ?~> "user.creation.failed" multiUser <- multiUserDAO.findOne(user._multiUser)(GlobalAccessContext) _ = analyticsService.track(SignupEvent(user, inviteBox.isDefined)) _ <- Fox.runIf(inviteBox.isDefined)(Fox.runOptional(inviteBox.toOption)(i => @@ -568,7 +575,8 @@ class AuthenticationController @Inject()( lastName, isActive = true, passwordHasher.hash(signUpData.password), - isAdmin = true) ?~> "user.creation.failed" + isAdmin = true, + isOrganizationOwner = true) ?~> "user.creation.failed" _ = analyticsService.track(SignupEvent(user, hadInvite = false)) multiUser <- multiUserDAO.findOne(user._multiUser) dataStoreToken <- bearerTokenAuthenticatorService diff --git a/app/controllers/InitialDataController.scala b/app/controllers/InitialDataController.scala index 804ab45ce4c..02e63ca9b28 100644 --- a/app/controllers/InitialDataController.scala +++ b/app/controllers/InitialDataController.scala @@ -96,6 +96,7 @@ Samplecountry Json.obj(), userService.createLoginInfo(userId), isAdmin = true, + isOrganizationOwner = true, isDatasetManager = true, isUnlisted = false, isDeactivated = false, @@ -117,6 +118,7 @@ Samplecountry Json.obj(), userService.createLoginInfo(userId2), isAdmin = false, + isOrganizationOwner = false, isDatasetManager = false, isUnlisted = false, isDeactivated = false, diff --git a/app/controllers/OrganizationController.scala b/app/controllers/OrganizationController.scala index 39a0df5f705..41d54d59608 100755 --- a/app/controllers/OrganizationController.scala +++ b/app/controllers/OrganizationController.scala @@ -80,6 +80,41 @@ class OrganizationController @Inject()(organizationDAO: OrganizationDAO, Ok(Json.toJson(conf.WebKnossos.operatorData)) } + def getTermsOfService: Action[AnyContent] = Action { + Ok( + Json.obj( + "version" -> conf.WebKnossos.TermsOfService.version, + "enabled" -> conf.WebKnossos.TermsOfService.enabled, + "url" -> conf.WebKnossos.TermsOfService.url + )) + } + + def termsOfServiceAcceptanceNeeded: Action[AnyContent] = sil.SecuredAction.async { implicit request => + for { + organization <- organizationDAO.findOne(request.identity._organization) + needsAcceptance = conf.WebKnossos.TermsOfService.enabled && + organization.lastTermsOfServiceAcceptanceVersion < conf.WebKnossos.TermsOfService.version + acceptanceDeadline = conf.WebKnossos.TermsOfService.acceptanceDeadline + deadlinePassed = acceptanceDeadline.toEpochMilli < System.currentTimeMillis() + } yield + Ok( + Json.obj( + "acceptanceNeeded" -> needsAcceptance, + "acceptanceDeadline" -> acceptanceDeadline.toEpochMilli, + "acceptanceDeadlinePassed" -> deadlinePassed + )) + } + + def acceptTermsOfService(version: Int): Action[AnyContent] = sil.SecuredAction.async { implicit request => + for { + _ <- bool2Fox(request.identity.isOrganizationOwner) ?~> "termsOfService.onlyOrganizationOwner" + _ <- bool2Fox(conf.WebKnossos.TermsOfService.enabled) ?~> "termsOfService.notEnabled" + requiredVersion = conf.WebKnossos.TermsOfService.version + _ <- bool2Fox(version == requiredVersion) ?~> Messages("termsOfService.versionMismatch", requiredVersion, version) + _ <- organizationDAO.acceptTermsOfService(request.identity._organization, version, System.currentTimeMillis()) + } yield Ok + } + def update(organizationName: String): Action[JsValue] = sil.SecuredAction.async(parse.json) { implicit request => withJsonBodyUsing(organizationUpdateReads) { case (displayName, newUserMailingList) => diff --git a/app/models/organization/Organization.scala b/app/models/organization/Organization.scala index 3b912c774a3..c663e892986 100755 --- a/app/models/organization/Organization.scala +++ b/app/models/organization/Organization.scala @@ -25,6 +25,8 @@ case class Organization( newUserMailingList: String = "", overTimeMailingList: String = "", enableAutoVerify: Boolean = false, + lastTermsOfServiceAcceptanceTime: Option[Instant] = None, + lastTermsOfServiceAcceptanceVersion: Int = 0, created: Instant = Instant.now, isDeleted: Boolean = false ) @@ -52,6 +54,8 @@ class OrganizationDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionCont r.newusermailinglist, r.overtimemailinglist, r.enableautoverify, + r.lasttermsofserviceacceptancetime.map(Instant.fromSql), + r.lasttermsofserviceacceptanceversion, Instant.fromSql(r.created), r.isdeleted ) @@ -83,10 +87,18 @@ class OrganizationDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionCont def insertOne(o: Organization): Fox[Unit] = for { - _ <- run( - sqlu"""insert into webknossos.organizations(_id, name, additionalInformation, logoUrl, displayName, _rootFolder, newUserMailingList, overTimeMailingList, enableAutoVerify, created, isDeleted) - values(${o._id.id}, ${o.name}, ${o.additionalInformation}, ${o.logoUrl}, ${o.displayName}, ${o._rootFolder}, - ${o.newUserMailingList}, ${o.overTimeMailingList}, ${o.enableAutoVerify}, ${o.created}, ${o.isDeleted}) + _ <- run(sqlu"""INSERT INTO webknossos.organizations( + _id, name, additionalInformation, logoUrl, displayName, _rootFolder, newUserMailingList, overTimeMailingList, + enableAutoVerify, lastTermsOfServiceAcceptanceTime, lastTermsOfServiceAcceptanceVersion, + created, isDeleted + ) + VALUES( + ${o._id}, ${o.name}, ${o.additionalInformation}, ${o.logoUrl}, ${o.displayName}, + ${o._rootFolder}, ${o.newUserMailingList}, ${o.overTimeMailingList}, ${o.enableAutoVerify}, + ${o.lastTermsOfServiceAcceptanceTime}, + ${o.lastTermsOfServiceAcceptanceVersion}, + ${o.created}, ${o.isDeleted} + ) """) } yield () @@ -116,4 +128,16 @@ class OrganizationDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionCont where _id = $organizationId""") } yield () + def acceptTermsOfService(organizationId: ObjectId, version: Int, timestamp: Long)( + implicit ctx: DBAccessContext): Fox[Unit] = + for { + _ <- assertUpdateAccess(organizationId) + _ <- run(sqlu"""UPDATE webknossos.organizations + SET + lastTermsOfServiceAcceptanceTime = ${new java.sql.Timestamp(timestamp)}, + lastTermsOfServiceAcceptanceVersion = $version + WHERE _id = $organizationId + """) + } yield () + } diff --git a/app/models/organization/OrganizationService.scala b/app/models/organization/OrganizationService.scala index 7f4e6115645..f537bb05a90 100644 --- a/app/models/organization/OrganizationService.scala +++ b/app/models/organization/OrganizationService.scala @@ -31,7 +31,9 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO, val adminOnlyInfo = if (requestingUser.exists(_.isAdminOf(organization._id))) { Json.obj( "newUserMailingList" -> organization.newUserMailingList, - "pricingPlan" -> organization.pricingPlan + "pricingPlan" -> organization.pricingPlan, + "lastTermsOfServiceAcceptanceTime" -> organization.lastTermsOfServiceAcceptanceTime, + "lastTermsOfServiceAcceptanceVersion" -> organization.lastTermsOfServiceAcceptanceVersion ) } else Json.obj() Fox.successful( diff --git a/app/models/user/User.scala b/app/models/user/User.scala index 76d2878a06f..f91a416c67d 100755 --- a/app/models/user/User.scala +++ b/app/models/user/User.scala @@ -33,6 +33,7 @@ case class User( userConfiguration: JsObject, loginInfo: LoginInfo, isAdmin: Boolean, + isOrganizationOwner: Boolean, isDatasetManager: Boolean, isDeactivated: Boolean, isUnlisted: Boolean, @@ -79,6 +80,7 @@ class UserDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) userConfiguration, LoginInfo(User.default_login_provider_id, r._Id), r.isadmin, + r.isorganizationowner, r.isdatasetmanager, r.isdeactivated, r.isunlisted, @@ -213,12 +215,20 @@ class UserDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) def insertOne(u: User): Fox[Unit] = for { - _ <- run(sqlu"""insert into webknossos.users(_id, _multiUser, _organization, firstName, lastName, lastActivity, - userConfiguration, isDeactivated, isAdmin, isDatasetManager, isUnlisted, created, isDeleted) - values(${u._id}, ${u._multiUser}, ${u._organization}, ${u.firstName}, ${u.lastName}, - ${u.lastActivity}, '#${sanitize(Json.toJson(u.userConfiguration).toString)}', - ${u.isDeactivated}, ${u.isAdmin}, ${u.isDatasetManager}, ${u.isUnlisted}, ${u.created}, ${u.isDeleted}) - """) + _ <- run(sqlu"""INSERT INTO webknossos.users( + _id, _multiUser, _organization, firstName, lastName, + lastActivity, userConfiguration, + isDeactivated, isAdmin, isOrganizationOwner, + isDatasetManager, isUnlisted, + created, isDeleted + ) + VALUES( + ${u._id}, ${u._multiUser}, ${u._organization}, ${u.firstName}, ${u.lastName}, + ${u.lastActivity}, '#${sanitize(Json.toJson(u.userConfiguration).toString)}', + ${u.isDeactivated}, ${u.isAdmin}, ${u.isOrganizationOwner}, + ${u.isDatasetManager}, ${u.isUnlisted}, + ${u.created}, ${u.isDeleted} + )""") } yield () def updateLastActivity(userId: ObjectId, lastActivity: Instant = Instant.now)( diff --git a/app/models/user/UserService.scala b/app/models/user/UserService.scala index 6f25c2e1ee7..66cb36d08f8 100755 --- a/app/models/user/UserService.scala +++ b/app/models/user/UserService.scala @@ -83,7 +83,8 @@ class UserService @Inject()(conf: WkConf, lastName: String, isActive: Boolean, passwordInfo: PasswordInfo, - isAdmin: Boolean = false): Fox[User] = { + isAdmin: Boolean, + isOrganizationOwner: Boolean): Fox[User] = { implicit val ctx: GlobalAccessContext.type = GlobalAccessContext for { _ <- Fox.assertTrue(multiUserDAO.emailNotPresentYet(email)(GlobalAccessContext)) ?~> "user.email.alreadyInUse" @@ -108,6 +109,7 @@ class UserService @Inject()(conf: WkConf, Json.obj(), LoginInfo(CredentialsProvider.ID, newUserId.id), isAdmin, + isOrganizationOwner, isDatasetManager = false, isDeactivated = !isActive, isUnlisted = false, @@ -320,6 +322,7 @@ class UserService @Inject()(conf: WkConf, "firstName" -> user.firstName, "lastName" -> user.lastName, "isAdmin" -> user.isAdmin, + "isOrganizationOwner" -> user.isOrganizationOwner, "isDatasetManager" -> user.isDatasetManager, "isActive" -> !user.isDeactivated, "teams" -> teamMembershipsJs, diff --git a/app/oxalis/security/CombinedAuthenticatorService.scala b/app/oxalis/security/CombinedAuthenticatorService.scala index 34c74dd2696..1476f512a57 100644 --- a/app/oxalis/security/CombinedAuthenticatorService.scala +++ b/app/oxalis/security/CombinedAuthenticatorService.scala @@ -1,7 +1,7 @@ package oxalis.security import com.mohiva.play.silhouette.api._ -import com.mohiva.play.silhouette.api.crypto.{Base64AuthenticatorEncoder, Signer} +import com.mohiva.play.silhouette.api.crypto.Base64AuthenticatorEncoder import com.mohiva.play.silhouette.api.services.{AuthenticatorResult, AuthenticatorService} import com.mohiva.play.silhouette.api.util.{Clock, ExtractableRequest, FingerprintGenerator, IDGenerator} import com.mohiva.play.silhouette.crypto.{JcaSigner, JcaSignerSettings} @@ -11,7 +11,6 @@ import play.api.mvc._ import utils.WkConf import scala.concurrent.{ExecutionContext, Future} -import scala.util.{Success, Try} /* * Combining BearerTokenAuthenticator and TokenAuthenticator from Silhouette @@ -26,11 +25,6 @@ case class CombinedAuthenticator(actualAuthenticator: StorableAuthenticator) ext override def isValid: Boolean = actualAuthenticator.isValid } -class IdentityCookieSigner extends Signer { - override def sign(data: String): String = data - override def extract(message: String): Try[String] = Success(message) -} - case class CombinedAuthenticatorService(cookieSettings: CookieAuthenticatorSettings, tokenSettings: BearerTokenAuthenticatorSettings, tokenDao: BearerTokenAuthenticatorRepository, diff --git a/app/utils/WkConf.scala b/app/utils/WkConf.scala index 97273a631a9..0cd40e467e7 100644 --- a/app/utils/WkConf.scala +++ b/app/utils/WkConf.scala @@ -4,6 +4,7 @@ import com.scalableminds.util.tools.ConfigReader import com.typesafe.scalalogging.LazyLogging import play.api.Configuration +import java.time.Instant import javax.inject.Inject import scala.concurrent.duration._ @@ -61,6 +62,13 @@ class WkConf @Inject()(configuration: Configuration) extends ConfigReader with L val children = List(User) } + object TermsOfService { + val enabled: Boolean = get[Boolean]("webKnossos.termsOfService.enabled") + val url: String = get[String]("webKnossos.termsOfService.url") + val acceptanceDeadline: Instant = get[Instant]("webKnossos.termsOfService.acceptanceDeadline") + val version: Int = get[Int]("webKnossos.termsOfService.version") + } + val operatorData: String = get[String]("webKnossos.operatorData") val children = List(User, Tasks, Cache, SampleOrganization) } diff --git a/conf/application.conf b/conf/application.conf index b4a12a5fe7a..b6950491e75 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -74,6 +74,13 @@ webKnossos { Please add the information of the operator to comply with GDPR. """ + termsOfService { + enabled = false + # The URL will be embedded into an iFrame + url = "https://webknossos.org/terms-of-service" + acceptanceDeadline = "2023-01-01T00:00:00Z" + version = 1 + } } singleSignOn { diff --git a/conf/evolutions/093-terms-of-service.sql b/conf/evolutions/093-terms-of-service.sql new file mode 100644 index 00000000000..765cc6083f2 --- /dev/null +++ b/conf/evolutions/093-terms-of-service.sql @@ -0,0 +1,28 @@ +BEGIN transaction; + +DROP VIEW webknossos.userInfos; +DROP VIEW webknossos.organizations_; +DROP VIEW webknossos.users_; + + +ALTER TABLE webknossos.users ADD COLUMN isOrganizationOwner BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE webknossos.organizations ADD COLUMN lastTermsOfServiceAcceptanceTime TIMESTAMPTZ; +ALTER TABLE webknossos.organizations ADD COLUMN lastTermsOfServiceAcceptanceVersion INT NOT NULL DEFAULT 0; + +CREATE VIEW webknossos.organizations_ AS SELECT * FROM webknossos.organizations WHERE NOT isDeleted; +CREATE VIEW webknossos.users_ AS SELECT * FROM webknossos.users WHERE NOT isDeleted; + +CREATE VIEW webknossos.userInfos AS +SELECT +u._id AS _user, m.email, u.firstName, u.lastname, o.displayName AS organization_displayName, +u.isDeactivated, u.isDatasetManager, u.isAdmin, m.isSuperUser, +u._organization, o.name AS organization_name, u.created AS user_created, +m.created AS multiuser_created, u._multiUser, m._lastLoggedInIdentity, u.lastActivity +FROM webknossos.users_ u +JOIN webknossos.organizations_ o ON u._organization = o._id +JOIN webknossos.multiUsers_ m on u._multiUser = m._id; + +UPDATE webknossos.releaseInformation SET schemaVersion = 93; + +COMMIT; + diff --git a/conf/evolutions/reversions/090-cleanup.sql b/conf/evolutions/reversions/090-cleanup.sql index a08cf758f6f..2cdae7ac471 100644 --- a/conf/evolutions/reversions/090-cleanup.sql +++ b/conf/evolutions/reversions/090-cleanup.sql @@ -52,7 +52,7 @@ ALTER TABLE webknossos.invites ALTER COLUMN _id SET DEFAULT ''; ALTER TABLE webknossos.annotation_privateLinks ALTER COLUMN _id SET DEFAULT ''; ALTER TABLE webknossos.shortLinks ALTER COLUMN _id SET DEFAULT ''; -UPDATE webknossos.releaseInformation SET schemaVersion = 90; +UPDATE webknossos.releaseInformation SET schemaVersion = 89; COMMIT; diff --git a/conf/evolutions/reversions/093-terms-of-service.sql b/conf/evolutions/reversions/093-terms-of-service.sql new file mode 100644 index 00000000000..32dc16c9d1d --- /dev/null +++ b/conf/evolutions/reversions/093-terms-of-service.sql @@ -0,0 +1,28 @@ +BEGIN transaction; + +DROP VIEW webknossos.userInfos; +DROP VIEW webknossos.organizations_; +DROP VIEW webknossos.users_; + + +ALTER TABLE webknossos.users DROP COLUMN isOrganizationOwner; +ALTER TABLE webknossos.organizations DROPCOLUMN lastTermsOfServiceAcceptanceTime; +ALTER TABLE webknossos.organizations DROPCOLUMN lastTermsOfServiceAcceptanceVersion; + +CREATE VIEW webknossos.organizations_ AS SELECT * FROM webknossos.organizations WHERE NOT isDeleted; +CREATE VIEW webknossos.users_ AS SELECT * FROM webknossos.users WHERE NOT isDeleted; + +CREATE VIEW webknossos.userInfos AS +SELECT +u._id AS _user, m.email, u.firstName, u.lastname, o.displayName AS organization_displayName, +u.isDeactivated, u.isDatasetManager, u.isAdmin, m.isSuperUser, +u._organization, o.name AS organization_name, u.created AS user_created, +m.created AS multiuser_created, u._multiUser, m._lastLoggedInIdentity, u.lastActivity +FROM webknossos.users_ u +JOIN webknossos.organizations_ o ON u._organization = o._id +JOIN webknossos.multiUsers_ m on u._multiUser = m._id; + +UPDATE webknossos.releaseInformation SET schemaVersion = 92; + +COMMIT; + diff --git a/conf/messages b/conf/messages index cb785412d32..c6b3bf4ff3a 100644 --- a/conf/messages +++ b/conf/messages @@ -38,6 +38,8 @@ organization.name.invalid=This organization name contains illegal characters. Pl organization.name.alreadyInUse=This name is already claimed by a different organization and not available anymore. Please choose a different name. organization.alreadyJoined=Your account is already associated with the selected organization. +termsOfService.versionMismatch=Terms of service version mismatch. Current version is {0}, received acceptance for {1} + user.notFound=User not found user.noAdmin=Access denied. Only admin users can execute this operation. user.deactivated=Your account has not been activated by an admin yet. Please contact your organization’s admin for help. diff --git a/conf/webknossos.latest.routes b/conf/webknossos.latest.routes index 79d85bda37e..5f334a74fd8 100644 --- a/conf/webknossos.latest.routes +++ b/conf/webknossos.latest.routes @@ -230,6 +230,9 @@ GET /organizations/:organizationName PATCH /organizations/:organizationName controllers.OrganizationController.update(organizationName: String) DELETE /organizations/:organizationName controllers.OrganizationController.delete(organizationName: String) GET /operatorData controllers.OrganizationController.getOperatorData +GET /termsOfService controllers.OrganizationController.getTermsOfService +POST /termsOfService/accept controllers.OrganizationController.acceptTermsOfService(version: Int) +GET /termsOfService/acceptanceNeeded controllers.OrganizationController.termsOfServiceAcceptanceNeeded # Timelogging GET /time/allusers/:year/:month controllers.TimeController.getWorkingHoursOfAllUsers(year: Int, month: Int, startDay: Option[Int], endDay: Option[Int]) diff --git a/frontend/javascripts/admin/api/terms_of_service.ts b/frontend/javascripts/admin/api/terms_of_service.ts new file mode 100644 index 00000000000..c1cf868c5a8 --- /dev/null +++ b/frontend/javascripts/admin/api/terms_of_service.ts @@ -0,0 +1,23 @@ +import Request from "libs/request"; + +export function getTermsOfService(): Promise<{ + url: string; + enabled: boolean; + version: number; +}> { + return Request.receiveJSON("/api/termsOfService"); +} + +export type AcceptanceInfo = { + acceptanceDeadline: number; + acceptanceDeadlinePassed: boolean; + acceptanceNeeded: boolean; +}; + +export async function requiresTermsOfServiceAcceptance(): Promise { + return await Request.receiveJSON("/api/termsOfService/acceptanceNeeded"); +} + +export function acceptTermsOfService(version: number): Promise { + return Request.receiveJSON(`/api/termsOfService/accept?version=${version}`, { method: "POST" }); +} diff --git a/frontend/javascripts/components/terms_of_services_check.tsx b/frontend/javascripts/components/terms_of_services_check.tsx new file mode 100644 index 00000000000..ac9e11812f0 --- /dev/null +++ b/frontend/javascripts/components/terms_of_services_check.tsx @@ -0,0 +1,164 @@ +import { + AcceptanceInfo, + acceptTermsOfService, + getTermsOfService, + requiresTermsOfServiceAcceptance, +} from "admin/api/terms_of_service"; +import { Modal, Spin } from "antd"; +import { AsyncButton } from "components/async_clickables"; +import { useFetch } from "libs/react_helpers"; +import UserLocalStorage from "libs/user_local_storage"; +import moment from "moment"; +import type { OxalisState } from "oxalis/store"; +import React, { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import { formatDateInLocalTimeZone } from "./formatted_date"; + +const SNOOZE_DURATION_IN_DAYS = 3; +const LAST_TERMS_OF_SERVICE_WARNING_KEY = "lastTermsOfServiceWarning"; + +export function CheckTermsOfServices() { + const [isModalOpen, setIsModalOpen] = useState(false); + const closeModal = () => { + UserLocalStorage.setItem(LAST_TERMS_OF_SERVICE_WARNING_KEY, String(Date.now())); + setIsModalOpen(false); + }; + const activeUser = useSelector((state: OxalisState) => state.activeUser); + const [recheckCounter, setRecheckCounter] = useState(0); + const acceptanceInfo = useFetch( + async () => { + if (activeUser == null) { + return null; + } + return await requiresTermsOfServiceAcceptance(); + }, + null, + [activeUser, recheckCounter], + ); + + useEffect(() => { + // Show ToS modal when the acceptance is needed and it wasn't snoozed + // (unless the deadline is exceeded). + if (!acceptanceInfo || !acceptanceInfo.acceptanceNeeded) { + return; + } + if (acceptanceInfo.acceptanceNeeded && acceptanceInfo.acceptanceDeadlinePassed) { + setIsModalOpen(true); + return; + } + + const lastWarningString = UserLocalStorage.getItem(LAST_TERMS_OF_SERVICE_WARNING_KEY); + const lastWarning = moment(lastWarningString ? parseInt(lastWarningString) : 0); + const isLastWarningOld = moment().diff(lastWarning, "days") > SNOOZE_DURATION_IN_DAYS; + setIsModalOpen(isLastWarningOld); + }, [acceptanceInfo]); + const onAccept = async (version: number) => { + await acceptTermsOfService(version); + setRecheckCounter((val) => val + 1); + }; + + if (!acceptanceInfo || !activeUser || !acceptanceInfo.acceptanceNeeded) { + return null; + } + + if (activeUser.isOrganizationOwner) { + return ( + + ); + } else { + return ( + + ); + } +} + +function AcceptTermsOfServiceModal({ + onAccept, + acceptanceInfo, + isModalOpen, + closeModal, +}: { + onAccept: (version: number) => Promise; + acceptanceInfo: AcceptanceInfo; + isModalOpen: boolean; + closeModal: () => void; +}) { + const terms = useFetch(getTermsOfService, null, []); + + const deadlineExplanation = getDeadlineExplanation(acceptanceInfo); + + return ( + (terms != null ? await onAccept(terms.version) : null)} + > + Accept + , + ]} + > +

+ + Please accept the following terms of services to continue using webKnossos.{" "} + {deadlineExplanation} + +

+ +
+ {terms == null ? ( + + ) : ( +