diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..644c120 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,79 @@ +stages: + - "Unit" + - "Build" + - "Lint" + +# Only build every commit on the master branch (pull requests on other branches are still built) +branches: + only: + - master + +jobs: + include: + ### Back-end + + # Back-end unit tests + #- stage: "Unit" + # name: "Unit test back-end" + # language: java + # jdk: openjdk11 + # before_script: + # - cd back-end + # script: + # - mvn test + + # Back-end build test + - stage: "Build" + name: "Build back-end" + language: java + jdk: openjdk11 + before_script: + - cd back-end + script: + - mvn package -DskipTests=true + + # Back-end lint test + - stage: "Lint" + name: "Lint back-end" + language: java + jdk: openjdk11 + before_script: + - cd back-end + script: + - mvn ktlint:check + + + ### Front-end + + # Front-end unit tests + - stage: "Unit" + name: "Unit test front-end" + language: node_js + node_js: 14 + before_script: + - cd front-end + script: + - npm install + - npm run test + + # Front-end build test + - stage: "Build" + name: "Build front-end" + language: node_js + node_js: 14 + before_script: + - cd front-end + script: + - npm install + - npm run build + + # Front-end lint test + - stage: "Lint" + name: "Lint front-end" + language: node_js + node_js: 14 + before_script: + - cd front-end + script: + - npm install + - npm run lint diff --git a/CHANGELOG.md b/CHANGELOG.md index cfa2bb5..418e7a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2021-07-06 + +### Added + +- Grant or revoke administrator privileges to / from users in the administrator page. +- Manage access to a domain in the domain edit page. +- Endpoint for changing the administrator status of users. +- (Admin only) endpoint for getting a list of all users. +- Endpoint to get all users with certain access to a domain. +- Endpoint to set a user's access to a domain. +- Endpoint to transfer the ownership of a domain. + +### Changed + +- More specific error messages are shown when a workflow run / synthesis gets interrupted. +- Change occurrences of "APE Web View" to "APE Web". +- Moved front-end Docker container from Node.js 12 to Node.js 14. +- Logging of back-end tests is less verbose (information about the tests themselves is not affected). +- User's email addresses are no longer included in responses from the back-end by default (previously email addresses were also only given when necessary). + +### Fixed + +- Test application.properties of back-end was in the wrong location. +- The 403 result on the domain edit page now redirects to the home page instead of a non-existent page. + ## [1.1.0] - 2021-05-28 ### Added @@ -57,5 +82,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Approve user accounts. [Unreleased]: https://github.com/sanctuuary/APE-Web/compare/master...dev +[1.2.0]: https://github.com/sanctuuary/APE-Web/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/sanctuuary/APE-Web/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/sanctuuary/APE-Web/releases/tag/v1.0.0 diff --git a/back-end/Dockerfile b/back-end/Dockerfile index 0960890..04ca785 100644 --- a/back-end/Dockerfile +++ b/back-end/Dockerfile @@ -6,4 +6,4 @@ COPY pom.xml /app RUN mvn compile COPY . /app RUN mvn package -DskipTests=true -P docker -ENTRYPOINT ["java", "-jar", "target/backend-1.1.0.jar"] +ENTRYPOINT ["java", "-jar", "target/backend-1.2.0.jar"] diff --git a/back-end/README.md b/back-end/README.md index 503e11a..bf3095b 100644 --- a/back-end/README.md +++ b/back-end/README.md @@ -66,3 +66,18 @@ You can now build the back-end using: ````shell $ mvn package -DskipTests=true ```` + +## Documentation + +The documentation of the back-end is generated using [Orchid](https://orchid.run/). +To generate and view the documentation, use: +```shell +mvn orchid:serve +``` +You can now view the documentation when you go to http://localhost:8080 in your browser. + +If you wish to only build the documentation, run: +```shell +mvn orchid:build +``` +The documentation is placed in `target/docs`. diff --git a/back-end/pom.xml b/back-end/pom.xml index 3e74930..42df71c 100644 --- a/back-end/pom.xml +++ b/back-end/pom.xml @@ -10,7 +10,7 @@ com.apexdevs backend - 1.1.0 + 1.2.0 backend APE Web back-end @@ -41,67 +41,67 @@ - - - - io.github.sanctuuary - APE - 1.1.7 - - - ch.qos.logback - logback-classic - - - org.slf4j - slf4j-simple - - - - - org.sat4j - org.sat4j.core - 2.3.1 - - - net.sourceforge.owlapi - owlapi-distribution - 5.1.17 - - - org.apache.commons - commons-lang3 - 3.10 - - - - com.google.code.gson - gson - 2.8.6 - - - org.springframework.data - spring-data-mongodb - - - org.springframework.boot - spring-boot-starter-data-mongodb - - - de.flapdoodle.embed - de.flapdoodle.embed.mongo - test - - - guru.nidi - graphviz-java - 0.17.0 - - - com.googlecode.json-simple - json-simple - 1.1.1 - + + + + io.github.sanctuuary + APE + 1.1.7 + + + ch.qos.logback + logback-classic + + + org.slf4j + slf4j-simple + + + + + org.sat4j + org.sat4j.core + 2.3.1 + + + net.sourceforge.owlapi + owlapi-distribution + 5.1.17 + + + org.apache.commons + commons-lang3 + 3.10 + + + + com.google.code.gson + gson + 2.8.6 + + + org.springframework.data + spring-data-mongodb + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + de.flapdoodle.embed + de.flapdoodle.embed.mongo + test + + + guru.nidi + graphviz-java + 0.17.0 + + + com.googlecode.json-simple + json-simple + 1.1.1 + org.springframework.boot spring-boot-starter-web @@ -227,7 +227,7 @@ org.jacoco jacoco-maven-plugin - 0.8.6 + 0.8.7 **/*/exception/* diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/ape/ApeRequest.kt b/back-end/src/main/kotlin/com/apexdevs/backend/ape/ApeRequest.kt index bbad5bc..444bfc0 100644 --- a/back-end/src/main/kotlin/com/apexdevs/backend/ape/ApeRequest.kt +++ b/back-end/src/main/kotlin/com/apexdevs/backend/ape/ApeRequest.kt @@ -14,12 +14,14 @@ import com.apexdevs.backend.ape.entity.workflow.WorkflowOutput import com.apexdevs.backend.persistence.RunParametersOperation import com.apexdevs.backend.persistence.database.entity.Domain import com.apexdevs.backend.persistence.exception.RunParametersExceedLimitsException +import com.apexdevs.backend.persistence.exception.SynthesisFlagException import guru.nidi.graphviz.attribute.Rank import nl.uu.cs.ape.sat.APE import nl.uu.cs.ape.sat.core.implSAT.SATsolutionsList import nl.uu.cs.ape.sat.core.solutionStructure.CWLCreator import nl.uu.cs.ape.sat.core.solutionStructure.ModuleNode import nl.uu.cs.ape.sat.core.solutionStructure.TypeNode +import nl.uu.cs.ape.sat.models.enums.SynthesisFlag import nl.uu.cs.ape.sat.models.logic.constructs.TaxonomyPredicate import java.io.ByteArrayOutputStream import java.nio.file.Path @@ -52,6 +54,7 @@ class ApeRequest(val domain: Domain, private val rootLocation: Path, val ape: AP * Runs APE with the current config, then parses it to a suitable FE format * @param: The amount of workflows wanted * @throws RunParametersExceedLimitsException When the given config exceeds the configured run parameters limits + * @throws SynthesisFlagException When the run is interrupted * @return: A list of resulting workflows */ fun getWorkflows(runConfig: RunConfig): MutableList { @@ -68,6 +71,11 @@ class ApeRequest(val domain: Domain, private val rootLocation: Path, val ape: AP runWithConfig(runConfig) val resultingWorkflows = mutableListOf() + // check if the run was interrupted + if (solutions.flag != SynthesisFlag.NONE) { + throw SynthesisFlagException(this, solutions.flag) + } + for (i in 0 until solutions.numberOfSolutions) { val solutionWorkflow = solutions.get(i) diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/persistence/DomainOperation.kt b/back-end/src/main/kotlin/com/apexdevs/backend/persistence/DomainOperation.kt index 6f4c170..c624155 100644 --- a/back-end/src/main/kotlin/com/apexdevs/backend/persistence/DomainOperation.kt +++ b/back-end/src/main/kotlin/com/apexdevs/backend/persistence/DomainOperation.kt @@ -246,4 +246,18 @@ class DomainOperation(val domainRepository: DomainRepository, val userRepository // Return true if user has access rights and these are better or equal to required access rights return userAccess.isPresent && userAccess.get().access >= requiredAccess } + + /** + * Gets all users who have one of the given access levels to a certain domain. + * @param domainId The id of the domain to which the users should have access. + * @param access The access levels which the users should have. + * @return A list of user id's with the access level the users have to the domain. + */ + fun getUsersByDomainAndAccess(domainId: ObjectId, access: List): List { + val usersByDomainAndAccess: MutableList = mutableListOf() + for (accessRight in access) { + usersByDomainAndAccess += userDomainAccessRepository.findAllByDomainIdAndAccess(domainId, accessRight) + } + return usersByDomainAndAccess.toList() + } } diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/persistence/UserOperation.kt b/back-end/src/main/kotlin/com/apexdevs/backend/persistence/UserOperation.kt index 36c7194..717b51f 100644 --- a/back-end/src/main/kotlin/com/apexdevs/backend/persistence/UserOperation.kt +++ b/back-end/src/main/kotlin/com/apexdevs/backend/persistence/UserOperation.kt @@ -6,6 +6,7 @@ package com.apexdevs.backend.persistence import com.apexdevs.backend.persistence.database.entity.AdminStatus import com.apexdevs.backend.persistence.database.entity.User +import com.apexdevs.backend.persistence.database.entity.UserAdmin import com.apexdevs.backend.persistence.database.entity.UserApproveRequest import com.apexdevs.backend.persistence.database.entity.UserRequest import com.apexdevs.backend.persistence.database.entity.UserStatus @@ -174,4 +175,47 @@ class UserOperation( return pendingInfo } } + + /** + * Get all approved users. + */ + fun approvedUsers(): List { + val approved = userApproveRequestRepository.findByStatus(UserRequest.Approved) + + return if (approved.isEmpty()) + emptyList() + else + approved.map { a -> userRepository.findById(a.userId).get() } + } + + /** + * Set the AdminStatus of a user. + * @throws UserNotFoundException When no user with the given userId could be found. + */ + fun setUserAdminStatus(userId: ObjectId, adminStatus: AdminStatus) { + // Check if the user exists + val user = userRepository.findById(userId) + if (user.isEmpty) { + throw UserNotFoundException(this, "User with id: $userId not found") + } + + val existing = userAdminRepository.findByUserId(userId) + if (existing.isPresent) { + // Edit a previously revoked UserAdmin entry + val userAdmin = existing.get() + val updated = UserAdmin( + userAdmin.id, + userAdmin.userId, + adminStatus + ) + userAdminRepository.save(updated) + } else { + // Add a new UserAdmin entry + val new = UserAdmin( + userId, + adminStatus + ) + userAdminRepository.insert(new) + } + } } diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/collection/DomainCollection.kt b/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/collection/DomainCollection.kt index 7701066..4a04704 100644 --- a/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/collection/DomainCollection.kt +++ b/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/collection/DomainCollection.kt @@ -38,8 +38,8 @@ class DomainCollection(val domainRepository: DomainRepository, val userDomainAcc */ fun getDomainsByUserAndAccess(user: User, access: List): List { val domainsByUserAndAccess: MutableList = mutableListOf() - for (accessLevel in access) { - domainsByUserAndAccess += userDomainAccessRepository.findAllByUserIdAndAccess(user.id, accessLevel) + for (accessRight in access) { + domainsByUserAndAccess += userDomainAccessRepository.findAllByUserIdAndAccess(user.id, accessRight) } return domainsByUserAndAccess.toList() diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/entity/UserAdmin.kt b/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/entity/UserAdmin.kt index 41d8fa4..9722d1f 100644 --- a/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/entity/UserAdmin.kt +++ b/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/entity/UserAdmin.kt @@ -7,6 +7,7 @@ package com.apexdevs.backend.persistence.database.entity import org.bson.types.ObjectId import org.springframework.data.annotation.Id import org.springframework.data.mongodb.core.index.Indexed +import org.springframework.data.mongodb.core.mapping.Document /** * Determines current admin status for user admin entry @@ -16,4 +17,8 @@ enum class AdminStatus { Revoked, Active } /** * Admin user document, for database storage */ -data class UserAdmin(@Id val id: ObjectId, @Indexed(unique = true) val userId: ObjectId, val adminStatus: AdminStatus) +@Document +class UserAdmin(@Id val id: ObjectId, @Indexed(unique = true) val userId: ObjectId, val adminStatus: AdminStatus) { + constructor(userId: ObjectId, adminStatus: AdminStatus) : + this(ObjectId.get(), userId, adminStatus) +} diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/repository/UserAdminRepository.kt b/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/repository/UserAdminRepository.kt index e263c85..e66d028 100644 --- a/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/repository/UserAdminRepository.kt +++ b/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/repository/UserAdminRepository.kt @@ -17,4 +17,5 @@ import java.util.Optional @Repository interface UserAdminRepository : MongoRepository { fun findByUserIdAndAdminStatus(userId: ObjectId, adminStatus: AdminStatus): Optional + fun findByUserId(userId: ObjectId): Optional } diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/repository/UserDomainAccessRepository.kt b/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/repository/UserDomainAccessRepository.kt index dc0f412..23f2349 100644 --- a/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/repository/UserDomainAccessRepository.kt +++ b/back-end/src/main/kotlin/com/apexdevs/backend/persistence/database/repository/UserDomainAccessRepository.kt @@ -16,4 +16,5 @@ interface UserDomainAccessRepository : MongoRepository fun findByDomainId(domainId: ObjectId): List fun findAllByUserIdAndAccess(userId: ObjectId, access: DomainAccess): List + fun findAllByDomainIdAndAccess(domainId: ObjectId, access: DomainAccess): List } diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/persistence/exception/SynthesisFlagException.kt b/back-end/src/main/kotlin/com/apexdevs/backend/persistence/exception/SynthesisFlagException.kt new file mode 100644 index 0000000..018aa71 --- /dev/null +++ b/back-end/src/main/kotlin/com/apexdevs/backend/persistence/exception/SynthesisFlagException.kt @@ -0,0 +1,16 @@ +package com.apexdevs.backend.persistence.exception + +import nl.uu.cs.ape.sat.models.enums.SynthesisFlag + +class SynthesisFlagException(val from: Any, val flag: SynthesisFlag) : RuntimeException(flag.message) { + /** + * Overrides some of APE's SynthesisFlag messages with user friendly messages which can be shown on the front-end. + * @return A user friendly message describing the reason the synthesis was interrupted. + */ + fun getFriendlyMessage(): String { + return when (flag) { + SynthesisFlag.TIMEOUT -> "Synthesis was interrupted because it reached the max duration." + else -> flag.message + } + } +} diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/api/ApiAdminController.kt b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/api/ApiAdminController.kt index b200796..8e309a6 100644 --- a/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/api/ApiAdminController.kt +++ b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/api/ApiAdminController.kt @@ -17,6 +17,7 @@ import com.apexdevs.backend.web.controller.entity.runparameters.RunParametersDet import com.apexdevs.backend.web.controller.entity.runparameters.RunParametersUploadRequest import com.apexdevs.backend.web.controller.entity.topic.TopicUploadRequest import com.apexdevs.backend.web.controller.entity.user.AdminApproveRequest +import com.apexdevs.backend.web.controller.entity.user.AdminStatusRequest import com.apexdevs.backend.web.controller.entity.user.PendingUserRequestInfo import org.bson.types.ObjectId import org.springframework.http.HttpStatus @@ -177,6 +178,30 @@ class ApiAdminController(val userOperation: UserOperation, val topicOperation: T } } + /** + * Change the admin status of a user. + */ + @ResponseStatus(HttpStatus.OK) + @PostMapping("/adminstatus") + fun setUserAdminStatus(@AuthenticationPrincipal admin: User, @RequestBody adminStatusRequest: AdminStatusRequest) { + try { + // Check if the user is an administrator + if (!userOperation.userIsAdmin(admin.username)) + throw AccessDeniedException("User: ${admin.username} is not allowed to access this route") + + userOperation.setUserAdminStatus(adminStatusRequest.userId, adminStatusRequest.adminStatus) + } catch (exc: Exception) { + when (exc) { + is AccessDeniedException -> + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, exc.message, exc) + is UserNotFoundException -> + throw ResponseStatusException(HttpStatus.BAD_REQUEST, exc.message, exc) + else -> + throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "", exc) + } + } + } + companion object { val log: Logger = Logger.getLogger("ApiAdminController_Logger") } diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/api/ApiDomainController.kt b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/api/ApiDomainController.kt index 15ed997..b1e6140 100644 --- a/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/api/ApiDomainController.kt +++ b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/api/ApiDomainController.kt @@ -20,6 +20,9 @@ import com.apexdevs.backend.persistence.filesystem.FileTypes import com.apexdevs.backend.persistence.filesystem.StorageService import com.apexdevs.backend.web.controller.entity.domain.DomainUploadRequest import com.apexdevs.backend.web.controller.entity.domain.DomainWithAccessResponse +import com.apexdevs.backend.web.controller.entity.domain.UserAccessUpload +import com.apexdevs.backend.web.controller.entity.domain.UserWithAccessResponse +import com.apexdevs.backend.web.controller.routing.DomainController import org.bson.types.ObjectId import org.springframework.core.io.Resource import org.springframework.http.HttpStatus @@ -32,6 +35,7 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestPart @@ -247,6 +251,144 @@ class ApiDomainController( } } + /** + * Get all users with access to a domain. + * @param user authenticated user principal, automatically retrieved from session + * @param domainId The id of the domain of which the users should have access + * @param accessRights The access levels the users may have + */ + @ResponseStatus(HttpStatus.OK) + @GetMapping("/users-with-access/{domainId}") + fun getUsersWithDomainAccess( + @AuthenticationPrincipal user: User, + @PathVariable domainId: ObjectId, + @RequestParam accessRights: List + ): List { + try { + // check if the user is the owner of the domain + val authUser = userOperation.getByEmail(user.username) + val domain = domainOperation.getDomain(domainId) + val requestUserIsOwner = domainOperation.hasUserAccess(domain, DomainAccess.Owner, authUser.id) + if (!requestUserIsOwner) + throw AccessDeniedException("User is not the owner of the domain") + + // get a list of all users who have access to the domain + val domainAccessList = domainOperation.getUsersByDomainAndAccess(domainId, accessRights) + // create a list of front-end safe objects to send back + val accessInfo: MutableList = mutableListOf() + for (domainAccess in domainAccessList) { + val userWithAccess = userOperation.userRepository.findById(domainAccess.userId) + if (userWithAccess.isEmpty) { + log.warning( + "Failed to find user with id ${domainAccess.userId}," + + "but a user with this id does have access to the domain with id: ${domainAccess.domainId}" + + " according to UserDomainAccess object with id: ${domainAccess.id}!" + ) + continue + } + + val info = UserWithAccessResponse( + domainAccess.id.toString(), + domainAccess.userId.toString(), + userWithAccess.get().displayName, + domainAccess.domainId.toString(), + domainAccess.access + ) + accessInfo.add(info) + } + return accessInfo + } catch (exc: Exception) { + when (exc) { + is AccessDeniedException -> + throw ResponseStatusException(HttpStatus.FORBIDDEN, exc.message, exc) + else -> + throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "", exc) + } + } + } + + /** + * Set the access a user has to a domain. + * @param user authenticated user principal, automatically retrieved from session + * @param id The domain to which the access is set + * @param userAccess The information to set the access (the user id and access level) + */ + @ResponseStatus(HttpStatus.OK) + @PostMapping("/{id}/access") + fun setUserAccess( + @AuthenticationPrincipal user: User, + @PathVariable id: ObjectId, + @RequestBody userAccess: UserAccessUpload + ) { + try { + // check if the user is the owner of the domain + val authUser = userOperation.getByEmail(user.username) + val domain = domainOperation.getDomain(id) + val requestUserIsOwner = domainOperation.hasUserAccess(domain, DomainAccess.Owner, authUser.id) + if (!requestUserIsOwner) + throw AccessDeniedException("User is not the owner of the domain") + + domainOperation.setUserAccess(id, userAccess.userId, userAccess.access) + } catch (exc: Exception) { + when (exc) { + is AccessDeniedException -> + throw ResponseStatusException(HttpStatus.FORBIDDEN, exc.message, exc) + is UserNotFoundException -> + throw ResponseStatusException(HttpStatus.BAD_REQUEST, exc.message, exc) + else -> + throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "", exc) + } + } + } + + /** + * Transfer ownership of a domain to a new user. + * The old owner will get ReadWrite access. + * @param user authenticated user principal, automatically retrieved from session. + * @param id the id of the domain to transfer ownership of. + * @param userId the user who will receive the ownership of the domain. + */ + @ResponseStatus(HttpStatus.OK) + @PostMapping("{id}/transfer/{userId}") + fun transferOwnership( + @AuthenticationPrincipal user: User, + @PathVariable id: ObjectId, + @PathVariable userId: ObjectId + ) { + try { + // check if the user is the owner of the domain + val authUser = userOperation.getByEmail(user.username) + val domain = domainOperation.getDomain(id) + val requestUserIsOwner = domainOperation.hasUserAccess(domain, DomainAccess.Owner, authUser.id) + if (!requestUserIsOwner) + throw AccessDeniedException("User is not the owner of the domain") + + // check if the new owner exists + val newOwner = userOperation.userRepository.findById(userId) + if (newOwner.isEmpty) + throw UserNotFoundException(this, "New owner with id: $userId not found") + + // transfer ownership + domainOperation.setUserAccess(id, userId, DomainAccess.Owner) + domainOperation.setUserAccess(id, authUser.id, DomainAccess.ReadWrite) + DomainController.log.info( + "Ownership of domain \"${domain.name}\" with id: ${domain.id} " + + "transferred to user \"${newOwner.get().displayName}\" with id $userId" + ) + } catch (exc: Exception) { + when (exc) { + is AccessDeniedException -> + throw ResponseStatusException(HttpStatus.FORBIDDEN, exc.message, exc) + is DomainNotFoundException -> + throw ResponseStatusException(HttpStatus.BAD_REQUEST, exc.message, exc) + is UserNotFoundException -> + throw ResponseStatusException(HttpStatus.BAD_REQUEST, exc.message, exc) + else -> + throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "", exc) + } + } + } + companion object { val log: Logger = Logger.getLogger("ApiDomainController_Logger") } diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/api/ApiWorkflowController.kt b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/api/ApiWorkflowController.kt index f38a97e..7888f1c 100644 --- a/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/api/ApiWorkflowController.kt +++ b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/api/ApiWorkflowController.kt @@ -14,6 +14,7 @@ import com.apexdevs.backend.ape.entity.workflow.WorkflowOutput import com.apexdevs.backend.persistence.UserOperation import com.apexdevs.backend.persistence.database.entity.UserStatus import com.apexdevs.backend.persistence.exception.RunParametersExceedLimitsException +import com.apexdevs.backend.persistence.exception.SynthesisFlagException import com.apexdevs.backend.persistence.filesystem.FileTypes import com.apexdevs.backend.persistence.filesystem.StorageService import org.json.JSONObject @@ -152,6 +153,8 @@ class ApiWorkflowController( throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Not enough rights for provided run parameters", exc) is RunParametersExceedLimitsException -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, exc.message, exc) + is SynthesisFlagException -> + throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, exc.getFriendlyMessage(), exc) else -> throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "", exc) } diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/entity/domain/UserAccessUpload.kt b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/entity/domain/UserAccessUpload.kt new file mode 100644 index 0000000..e25a237 --- /dev/null +++ b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/entity/domain/UserAccessUpload.kt @@ -0,0 +1,11 @@ +package com.apexdevs.backend.web.controller.entity.domain + +import com.apexdevs.backend.persistence.database.entity.DomainAccess +import org.bson.types.ObjectId + +/** + * Object to allow the front-end to set the access level of a user in a domain. + * @param userId The id of the user who will receive access. + * @param access The access level the user gets. + */ +class UserAccessUpload(val userId: ObjectId, val access: DomainAccess) diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/entity/domain/UserWithAccessResponse.kt b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/entity/domain/UserWithAccessResponse.kt new file mode 100644 index 0000000..6aa9f42 --- /dev/null +++ b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/entity/domain/UserWithAccessResponse.kt @@ -0,0 +1,19 @@ +package com.apexdevs.backend.web.controller.entity.domain + +import com.apexdevs.backend.persistence.database.entity.DomainAccess + +/** + * Class to send users with their access level to a domain to the front-end. + * @param id unique identifier of the UserDomainAccess object. + * @param userId The id of the user who has access. + * @param userDisplayName The display name of the user. + * @param domainId The id of the domain to which the user has access. + * @param accessRight The DomainAccess level the user has to the domain. + */ +data class UserWithAccessResponse( + val id: String, + val userId: String, + val userDisplayName: String, + val domainId: String, + val accessRight: DomainAccess +) diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/entity/user/AdminStatusRequest.kt b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/entity/user/AdminStatusRequest.kt new file mode 100644 index 0000000..d040f21 --- /dev/null +++ b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/entity/user/AdminStatusRequest.kt @@ -0,0 +1,9 @@ +package com.apexdevs.backend.web.controller.entity.user + +import com.apexdevs.backend.persistence.database.entity.AdminStatus +import org.bson.types.ObjectId + +/** + * Request to change the AdminStatus of a user. + */ +data class AdminStatusRequest(val userId: ObjectId, val adminStatus: AdminStatus) diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/entity/user/UserInfo.kt b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/entity/user/UserInfo.kt index a43dc83..d8f3b65 100644 --- a/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/entity/user/UserInfo.kt +++ b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/entity/user/UserInfo.kt @@ -15,9 +15,18 @@ import com.apexdevs.backend.persistence.database.entity.UserStatus * @param status status of user (Pending, Approved, Revoked) * @param isAdmin does the user have admin status */ -data class UserInfo(val userId: String, val email: String, val displayName: String, val status: UserStatus, val isAdmin: Boolean) { +data class UserInfo(val userId: String, var email: String?, val displayName: String, val status: UserStatus, val isAdmin: Boolean) { /** * Constructor using User entity from database. Only passes non-security information + * @param user The user whose information to use. + * @param isAdmin Whether the user is an administrator. + * @param includeMail Whether the email address of the user should be included. */ - constructor(user: User, isAdmin: Boolean) : this(user.id.toString(), user.email, user.displayName, user.status, isAdmin) + constructor(user: User, isAdmin: Boolean, includeMail: Boolean = false) : + this(user.id.toString(), user.email, user.displayName, user.status, isAdmin) { + // only show the user's email address when explicitly specified + if (!includeMail) { + this.email = null + } + } } diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/routing/UserController.kt b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/routing/UserController.kt index e691533..e662feb 100644 --- a/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/routing/UserController.kt +++ b/back-end/src/main/kotlin/com/apexdevs/backend/web/controller/routing/UserController.kt @@ -62,7 +62,7 @@ class UserController(val userOperation: UserOperation) { val isAdmin = userOperation.userIsAdmin(userResult.get().email) // create user info as response - return UserInfo(userResult.get(), isAdmin) + return UserInfo(userResult.get(), isAdmin, true) } catch (exc: Exception) { when (exc) { is HttpClientErrorException.BadRequest, is IllegalArgumentException -> @@ -76,4 +76,46 @@ class UserController(val userOperation: UserOperation) { } } } + + /** + * Get all approved users. + */ + @ResponseStatus(HttpStatus.OK) + @GetMapping("/") + fun getUsers(@AuthenticationPrincipal user: User): List { + try { + // Check if the user is an administrator + if (!userOperation.userIsAdmin(user.username)) + throw AccessDeniedException("User: ${user.username} is not allowed to access this route") + + val users = userOperation.approvedUsers() + return users.map { u -> UserInfo(u, userOperation.userIsAdmin(u.email), false) } + } catch (exc: Exception) { + when (exc) { + is AccessDeniedException -> + throw ResponseStatusException(HttpStatus.FORBIDDEN, "User not authorized to view this profile", exc) + else -> + throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "", exc) + } + } + } + + /** + * Find a user by their e-mail address. + */ + @ResponseStatus(HttpStatus.OK) + @GetMapping("/email/{email}") + fun findUserByEmail(@AuthenticationPrincipal user: User, @PathVariable email: String): UserInfo { + try { + val foundUser = userOperation.getByEmail(email) + return UserInfo(foundUser, false) + } catch (exc: Exception) { + when (exc) { + is UserNotFoundException -> + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "User with given e-mail address not found", exc) + else -> + throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "", exc) + } + } + } } diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/web/security/SecurityHttpConfig.kt b/back-end/src/main/kotlin/com/apexdevs/backend/web/security/SecurityHttpConfig.kt index 3a1aac4..b3fa9ea 100644 --- a/back-end/src/main/kotlin/com/apexdevs/backend/web/security/SecurityHttpConfig.kt +++ b/back-end/src/main/kotlin/com/apexdevs/backend/web/security/SecurityHttpConfig.kt @@ -84,6 +84,7 @@ class SecurityHttpConfig(val userDetailsService: UserDetailsService, val passwor .antMatchers(HttpMethod.GET, "/login").permitAll() // Temporary default login .antMatchers(HttpMethod.GET, "/domain", "/domain/**", "/workflow/**").permitAll() .antMatchers(HttpMethod.GET, "/topic", "/topic/**").permitAll() + .antMatchers(HttpMethod.GET, "/user/").hasRole("ADMIN") .antMatchers(HttpMethod.GET, "/user/**").hasAnyRole("USER", "UNAPPROVED") .antMatchers(HttpMethod.GET, "/api/domain/with-user-access").hasRole("USER") .antMatchers(HttpMethod.GET, "/api/workflow/**").permitAll() diff --git a/back-end/src/main/kotlin/com/apexdevs/backend/web/security/authentication/UsernamePasswordAPISuccessHandler.kt b/back-end/src/main/kotlin/com/apexdevs/backend/web/security/authentication/UsernamePasswordAPISuccessHandler.kt index f57ec99..ca2b7c7 100644 --- a/back-end/src/main/kotlin/com/apexdevs/backend/web/security/authentication/UsernamePasswordAPISuccessHandler.kt +++ b/back-end/src/main/kotlin/com/apexdevs/backend/web/security/authentication/UsernamePasswordAPISuccessHandler.kt @@ -51,7 +51,7 @@ class UsernamePasswordAPISuccessHandler(val userOperation: UserOperation) : Simp val isAdmin = userOperation.userIsAdmin(principal.username) // wrap user result into JSON object - val json = JSONObject.wrap(UserInfo(userResult.get(), isAdmin)) + val json = JSONObject.wrap(UserInfo(userResult.get(), isAdmin, true)) // write JSON into response response?.writer?.write(json.toString()) } else { diff --git a/back-end/src/main/resources/application-test.properties b/back-end/src/main/resources/application-test.properties deleted file mode 100644 index 03ef29e..0000000 --- a/back-end/src/main/resources/application-test.properties +++ /dev/null @@ -1 +0,0 @@ -spring.data.mongodb.port=0 \ No newline at end of file diff --git a/back-end/src/test/kotlin/com/apexdevs/backend/ape/ApeRequestTest.kt b/back-end/src/test/kotlin/com/apexdevs/backend/ape/ApeRequestTest.kt index fee2ea8..aec8c19 100644 --- a/back-end/src/test/kotlin/com/apexdevs/backend/ape/ApeRequestTest.kt +++ b/back-end/src/test/kotlin/com/apexdevs/backend/ape/ApeRequestTest.kt @@ -27,6 +27,7 @@ import nl.uu.cs.ape.sat.core.solutionStructure.SolutionWorkflow import nl.uu.cs.ape.sat.core.solutionStructure.TypeNode import nl.uu.cs.ape.sat.models.AllModules import nl.uu.cs.ape.sat.models.AllTypes +import nl.uu.cs.ape.sat.models.enums.SynthesisFlag import nl.uu.cs.ape.sat.models.logic.constructs.TaxonomyPredicate import nl.uu.cs.ape.sat.utils.APEDomainSetup import org.json.JSONObject @@ -72,6 +73,7 @@ internal class ApeRequestTest { every { mockPath.toString() } returns "Test" every { mockSolutionList.numberOfSolutions } returns 1 every { mockSolutionList.get(any()) } returns mockSolutionWorkflow + every { mockSolutionList.flag } returns SynthesisFlag.NONE every { mockSolutionWorkflow.moduleNodes.size } returns 1 every { mockSolutionWorkflow.workflowInputTypeStates } returns listOf(mockTypeNode) every { mockSolutionWorkflow.workflowOutputTypeStates } returns listOf(mockTypeNode) @@ -108,6 +110,7 @@ internal class ApeRequestTest { every { mockPath.toString() } returns "Test" every { mockSolutionList.numberOfSolutions } returns 1 every { mockSolutionList.get(any()) } returns mockSolutionWorkflow + every { mockSolutionList.flag } returns SynthesisFlag.NONE every { mockSolutionWorkflow.moduleNodes.size } returns 0 every { runParametersOperation.getGlobalRunParameters() } returns RunParameters() every { mockConfig.solutionMinLength } returns 30 diff --git a/back-end/src/test/kotlin/com/apexdevs/backend/persistence/DomainOperationTest.kt b/back-end/src/test/kotlin/com/apexdevs/backend/persistence/DomainOperationTest.kt index 10cb23e..39ea21a 100644 --- a/back-end/src/test/kotlin/com/apexdevs/backend/persistence/DomainOperationTest.kt +++ b/back-end/src/test/kotlin/com/apexdevs/backend/persistence/DomainOperationTest.kt @@ -29,6 +29,7 @@ import io.mockk.mockk import io.mockk.slot import io.mockk.spyk import org.bson.types.ObjectId +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -727,6 +728,28 @@ class DomainOperationTest() { assert(domain.strictToolsAnnotations) } + @Test + fun `Assert get users by domain and access works`() { + // create domain and user + val domain = getTestDomain(DomainVisibility.Private) + val user1 = User("user1@test.test", "passTest", "user1", UserStatus.Approved) + val user2 = User("user2@test.test", "passTest", "user2", UserStatus.Approved) + + val access1 = UserDomainAccess(user1.id, domain.id, DomainAccess.Owner) + val access2 = UserDomainAccess(user2.id, domain.id, DomainAccess.ReadWrite) + + every { userDomainAccessRepository.findAllByDomainIdAndAccess(domain.id, DomainAccess.Owner) } returns + listOf(access1) + + every { userDomainAccessRepository.findAllByDomainIdAndAccess(domain.id, DomainAccess.ReadWrite) } returns + listOf(access2) + + val domainOperation = spyk(getDomainOperation()) + val result = domainOperation.getUsersByDomainAndAccess(domain.id, listOf(DomainAccess.Owner, DomainAccess.ReadWrite)) + assertEquals(2, result.size) + assert(result.containsAll(listOf(access1, access2))) + } + /** * Perform update with DomainRepository.save() with all options in authorization */ diff --git a/back-end/src/test/kotlin/com/apexdevs/backend/web/controller/api/ApiDomainControllerTest.kt b/back-end/src/test/kotlin/com/apexdevs/backend/web/controller/api/ApiDomainControllerTest.kt index f2c922b..d0d908e 100644 --- a/back-end/src/test/kotlin/com/apexdevs/backend/web/controller/api/ApiDomainControllerTest.kt +++ b/back-end/src/test/kotlin/com/apexdevs/backend/web/controller/api/ApiDomainControllerTest.kt @@ -21,6 +21,8 @@ import com.apexdevs.backend.persistence.exception.UserNotFoundException import com.apexdevs.backend.persistence.filesystem.StorageService import com.apexdevs.backend.web.controller.entity.domain.DomainUploadRequest import com.apexdevs.backend.web.controller.entity.domain.DomainWithAccessResponse +import com.apexdevs.backend.web.controller.entity.domain.UserAccessUpload +import com.apexdevs.backend.web.controller.entity.domain.UserWithAccessResponse import com.mongodb.MongoException import io.mockk.Runs import io.mockk.every @@ -40,6 +42,8 @@ import org.springframework.http.ResponseEntity import org.springframework.security.core.userdetails.User import org.springframework.web.multipart.MultipartFile import org.springframework.web.server.ResponseStatusException +import java.util.Optional +import com.apexdevs.backend.persistence.database.entity.User as DatabaseEntityUser @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class ApiDomainControllerTest { @@ -51,7 +55,7 @@ internal class ApiDomainControllerTest { private val mockDomainCollection = mockk() private val mockSpringUser = mockk() private val mockFile = mockk() - private val mockUser = mockk() + private val mockUser = mockk() private val id = ObjectId() private val apiDomainController = ApiDomainController( @@ -366,4 +370,287 @@ internal class ApiDomainControllerTest { val exc = assertThrows { apiDomainController.getDomainsWithUserAccess(mockSpringUser, id, domainAccess) } assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, exc.status) } + + /** + * Method: getUsersWithDomainAccess + */ + @Test + fun `Get users with domain access`() { + val domainAccess = listOf(DomainAccess.Read) + val domain = Domain("Test", "Test", "Test", DomainVisibility.Public, "Test", "Test", listOf(), true) + val userDomainAccess = UserDomainAccess(mockUser.id, domain.id, DomainAccess.Read) + + every { mockUser.id } returns id + every { mockUser.displayName } returns "testUser" + every { mockDomainOperation.getDomain(any()) } returns domain + every { mockDomainOperation.hasUserAccess(domain, DomainAccess.Owner, mockUser.id) } returns true + every { mockDomainOperation.getUsersByDomainAndAccess(domain.id, any()) } returns listOf(userDomainAccess) + every { mockUserOperation.userRepository.findById(any()) } returns Optional.of(mockUser) + + val expected = listOf( + UserWithAccessResponse( + userDomainAccess.id.toHexString(), + userDomainAccess.userId.toHexString(), + mockUser.displayName, + userDomainAccess.domainId.toString(), + userDomainAccess.access + ) + ) + + assertEquals(expected, apiDomainController.getUsersWithDomainAccess(mockSpringUser, domain.id, domainAccess)) + } + + /** + * Method: getUsersWithDomainAccess + */ + @Test + fun `Get users with domain access throws access denied`() { + val domainAccess = listOf(DomainAccess.Read) + val domain = Domain( + "Test", + "Test", + "Test", + DomainVisibility.Public, + "Test", + "Test", + listOf(), + true + ) + + every { mockUser.id } returns id + every { mockUserOperation.getByEmail(mockSpringUser.username) } returns mockUser + every { mockDomainOperation.getDomain(domain.id) } returns domain + every { mockDomainOperation.hasUserAccess(domain, DomainAccess.Owner, mockUser.id) } returns false + + val exc = assertThrows { + apiDomainController.getUsersWithDomainAccess(mockSpringUser, domain.id, domainAccess) + } + assertEquals(HttpStatus.FORBIDDEN, exc.status) + } + + /** + * Method: setUserAccess + */ + @Test + fun `Set user access`() { + val domain = Domain( + "Test", + "Test", + "Test", + DomainVisibility.Public, + "Test", + "Test", + listOf(), + true + ) + val user = DatabaseEntityUser("user@test.test", "test", "TestUser", UserStatus.Approved) + val userAccessUpload = UserAccessUpload(user.id, DomainAccess.ReadWrite) + + every { mockUserOperation.getByEmail(mockSpringUser.username) } returns mockUser + every { mockDomainOperation.getDomain(domain.id) } returns domain + every { mockDomainOperation.hasUserAccess(domain, DomainAccess.Owner, mockUser.id) } returns true + every { mockDomainOperation.setUserAccess(domain.id, userAccessUpload.userId, userAccessUpload.access) } returns Unit + + assertEquals(Unit, apiDomainController.setUserAccess(mockSpringUser, domain.id, userAccessUpload)) + } + + /** + * Method: setUserAccess + */ + @Test + fun `Set user access throws access denied`() { + val domain = Domain( + "Test", + "Test", + "Test", + DomainVisibility.Public, + "Test", + "Test", + listOf(), + true + ) + val user = DatabaseEntityUser("user@test.test", "test", "TestUser", UserStatus.Approved) + val userAccessUpload = UserAccessUpload(user.id, DomainAccess.ReadWrite) + + every { mockUserOperation.getByEmail(mockSpringUser.username) } returns mockUser + every { mockDomainOperation.getDomain(domain.id) } returns domain + every { mockDomainOperation.hasUserAccess(domain, DomainAccess.Owner, mockUser.id) } returns false + + val exc = assertThrows { + apiDomainController.setUserAccess(mockSpringUser, domain.id, userAccessUpload) + } + assertEquals(HttpStatus.FORBIDDEN, exc.status) + } + + /** + * Method: setUserAccess + */ + @Test + fun `Set user access throws user not found`() { + val domain = Domain( + "Test", + "Test", + "Test", + DomainVisibility.Public, + "Test", + "Test", + listOf(), + true + ) + val user = DatabaseEntityUser("user@test.test", "test", "TestUser", UserStatus.Approved) + val userAccessUpload = UserAccessUpload(user.id, DomainAccess.ReadWrite) + + every { mockUserOperation.getByEmail(mockSpringUser.username) } returns mockUser + every { mockDomainOperation.getDomain(domain.id) } returns domain + every { mockDomainOperation.hasUserAccess(domain, DomainAccess.Owner, mockUser.id) } returns true + every { mockDomainOperation.setUserAccess(domain.id, userAccessUpload.userId, userAccessUpload.access) } throws + UserNotFoundException(this, "Invalid user with id: ${user.id}, domain access not updated") + + val exc = assertThrows { + apiDomainController.setUserAccess(mockSpringUser, domain.id, userAccessUpload) + } + assertEquals(HttpStatus.BAD_REQUEST, exc.status) + } + + /** + * Method: setUserAccess + */ + @Test + fun `Set user access throws domain not found`() { + val domain = Domain( + "Test", + "Test", + "Test", + DomainVisibility.Public, + "Test", + "Test", + listOf(), + true + ) + val user = DatabaseEntityUser("user@test.test", "test", "TestUser", UserStatus.Approved) + val userAccessUpload = UserAccessUpload(user.id, DomainAccess.ReadWrite) + + every { mockUserOperation.getByEmail(mockSpringUser.username) } returns mockUser + every { mockDomainOperation.getDomain(domain.id) } returns domain + every { mockDomainOperation.hasUserAccess(domain, DomainAccess.Owner, mockUser.id) } returns true + every { mockDomainOperation.setUserAccess(domain.id, userAccessUpload.userId, userAccessUpload.access) } throws + DomainNotFoundException(this, domain.id, "Invalid domain, domain access not updated") + + val exc = assertThrows { + apiDomainController.setUserAccess(mockSpringUser, domain.id, userAccessUpload) + } + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, exc.status) + } + + /** + * Method: transferOwnership + */ + @Test + fun `Transfer ownership`() { + val domain = Domain( + "Test", + "Test", + "Test", + DomainVisibility.Public, + "Test", + "Test", + listOf(), + true + ) + val user = DatabaseEntityUser("user@test.test", "test", "TestUser", UserStatus.Approved) + + every { mockUserOperation.getByEmail(mockSpringUser.username) } returns mockUser + every { mockDomainOperation.getDomain(domain.id) } returns domain + every { mockDomainOperation.hasUserAccess(domain, DomainAccess.Owner, mockUser.id) } returns true + every { mockUserOperation.userRepository.findById(user.id) } returns Optional.of(user) + every { mockDomainOperation.setUserAccess(domain.id, user.id, DomainAccess.Owner) } returns Unit + every { mockDomainOperation.setUserAccess(domain.id, mockUser.id, DomainAccess.ReadWrite) } returns Unit + + assertEquals(Unit, apiDomainController.transferOwnership(mockSpringUser, domain.id, user.id)) + } + + /** + * Method: transferOwnership + */ + @Test + fun `Transfer ownership throws access denied`() { + val domain = Domain( + "Test", + "Test", + "Test", + DomainVisibility.Public, + "Test", + "Test", + listOf(), + true + ) + val user = DatabaseEntityUser("user@test.test", "test", "TestUser", UserStatus.Approved) + + every { mockUserOperation.getByEmail(mockSpringUser.username) } returns mockUser + every { mockDomainOperation.getDomain(domain.id) } returns domain + every { mockDomainOperation.hasUserAccess(domain, DomainAccess.Owner, mockUser.id) } returns false + + val exc = assertThrows { + apiDomainController.transferOwnership(mockSpringUser, domain.id, user.id) + } + assertEquals(HttpStatus.FORBIDDEN, exc.status) + } + + /** + * Method: transferOwnership + */ + @Test + fun `Transfer ownership throws domain not found`() { + val domain = Domain( + "Test", + "Test", + "Test", + DomainVisibility.Public, + "Test", + "Test", + listOf(), + true + ) + val user = DatabaseEntityUser("user@test.test", "test", "TestUser", UserStatus.Approved) + + every { mockUserOperation.getByEmail(mockSpringUser.username) } returns mockUser + every { mockDomainOperation.getDomain(domain.id) } returns domain + every { mockDomainOperation.hasUserAccess(domain, DomainAccess.Owner, mockUser.id) } returns true + every { mockUserOperation.userRepository.findById(user.id) } returns Optional.of(user) + every { mockDomainOperation.setUserAccess(domain.id, user.id, DomainAccess.Owner) } throws + DomainNotFoundException(this, domain.id, "Invalid domain, domain access not updated") + + val exc = assertThrows { + apiDomainController.transferOwnership(mockSpringUser, domain.id, user.id) + } + assertEquals(HttpStatus.BAD_REQUEST, exc.status) + } + + /** + * Method: transferOwnership + */ + @Test + fun `Transfer ownership throws user not found`() { + val domain = Domain( + "Test", + "Test", + "Test", + DomainVisibility.Public, + "Test", + "Test", + listOf(), + true + ) + val user = DatabaseEntityUser("user@test.test", "test", "TestUser", UserStatus.Approved) + + every { mockUserOperation.getByEmail(mockSpringUser.username) } returns mockUser + every { mockDomainOperation.getDomain(domain.id) } returns domain + every { mockDomainOperation.hasUserAccess(domain, DomainAccess.Owner, mockUser.id) } returns true + every { mockUserOperation.userRepository.findById(user.id) } returns Optional.empty() + + val exc = assertThrows { + apiDomainController.transferOwnership(mockSpringUser, domain.id, user.id) + } + assertEquals(HttpStatus.BAD_REQUEST, exc.status) + } } diff --git a/back-end/src/test/kotlin/com/apexdevs/backend/web/controller/entity/user/UserInfoTest.kt b/back-end/src/test/kotlin/com/apexdevs/backend/web/controller/entity/user/UserInfoTest.kt new file mode 100644 index 0000000..3b4f6c5 --- /dev/null +++ b/back-end/src/test/kotlin/com/apexdevs/backend/web/controller/entity/user/UserInfoTest.kt @@ -0,0 +1,39 @@ +package com.apexdevs.backend.web.controller.entity.user + +import com.apexdevs.backend.persistence.database.entity.User +import com.apexdevs.backend.persistence.database.entity.UserStatus +import org.bson.types.ObjectId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +internal class UserInfoTest { + /** + * Test if the regular constructor works. + */ + @Test + fun regularConstructor() { + val user = User(ObjectId(), "user@test.test", "test", "test", UserStatus.Approved) + val userInfo = UserInfo(user, false) + assertEquals(userInfo.userId, user.id.toString()) + assertNull(userInfo.email) + assertEquals(userInfo.displayName, user.displayName) + assertEquals(userInfo.status, user.status) + assertEquals(userInfo.isAdmin, false) + } + + /** + * Test if the optional "includeEmail" argument works as intended. + */ + @Test + fun includeEmailConstructor() { + val user = User(ObjectId(), "user@test.test", "test", "test", UserStatus.Approved) + val isAdmin = true + val userInfo = UserInfo(user, isAdmin, true) + assertEquals(userInfo.userId, user.id.toString()) + assertEquals(userInfo.email, user.email) + assertEquals(userInfo.displayName, user.displayName) + assertEquals(userInfo.status, user.status) + assertEquals(userInfo.isAdmin, isAdmin) + } +} diff --git a/back-end/src/test/kotlin/com/apexdevs/backend/web/controller/routing/UserControllerMVCTest.kt b/back-end/src/test/kotlin/com/apexdevs/backend/web/controller/routing/UserControllerMVCTest.kt index 613dff1..24aec53 100644 --- a/back-end/src/test/kotlin/com/apexdevs/backend/web/controller/routing/UserControllerMVCTest.kt +++ b/back-end/src/test/kotlin/com/apexdevs/backend/web/controller/routing/UserControllerMVCTest.kt @@ -4,13 +4,10 @@ */ package com.apexdevs.backend.web.controller.routing -import com.apexdevs.backend.persistence.DomainOperation -import com.apexdevs.backend.persistence.TopicOperation import com.apexdevs.backend.persistence.UserOperation import com.apexdevs.backend.persistence.database.entity.User import com.apexdevs.backend.persistence.database.entity.UserStatus import com.apexdevs.backend.persistence.database.repository.UserRepository -import com.apexdevs.backend.persistence.filesystem.DomainFileService import com.apexdevs.backend.web.security.SecurityMVCTestConfig import com.apexdevs.backend.web.security.SecurityTestConfig import com.ninjasquad.springmockk.MockkBean @@ -26,6 +23,8 @@ import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.context.web.WebAppConfiguration import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.web.context.WebApplicationContext @@ -36,12 +35,6 @@ import java.util.Optional @ContextConfiguration(classes = [SecurityMVCTestConfig::class, SecurityTestConfig::class, UserController::class]) @WebAppConfiguration class UserControllerMVCTest(@Autowired val context: WebApplicationContext) { - @MockkBean(relaxed = true) - private lateinit var storageServiceDomain: DomainFileService - @MockkBean(relaxed = true) - private lateinit var domainOperation: DomainOperation - @MockkBean(relaxed = true) - private lateinit var topicOperation: TopicOperation @MockkBean private lateinit var userOperation: UserOperation @MockkBean @@ -135,4 +128,28 @@ class UserControllerMVCTest(@Autowired val context: WebApplicationContext) { status { isInternalServerError } } } + + @Test + @WithUserDetails("admin@test.test") + fun `Assert getUsers is accessible by admins`() { + val user = User(ObjectId(), "user@test.test", "test", "test", UserStatus.Approved) + every { userOperation.userIsAdmin(any()) } returns true + every { userOperation.approvedUsers() } returns listOf(user) + + mockMvc.perform( + MockMvcRequestBuilders.get("/user/") + ).andExpect(MockMvcResultMatchers.status().isOk) + } + + @Test + @WithUserDetails("user@test.test") + fun `Assert getUsers is not accessible by regular users`() { + val user = User(ObjectId(), "user@test.test", "test", "test", UserStatus.Approved) + every { userOperation.userIsAdmin(any()) } returns false + every { userOperation.approvedUsers() } returns listOf(user) + + mockMvc.perform( + MockMvcRequestBuilders.get("/user/") + ).andExpect(MockMvcResultMatchers.status().isForbidden) + } } diff --git a/back-end/src/test/resources/application.properties b/back-end/src/test/resources/application.properties new file mode 100644 index 0000000..ccab7c3 --- /dev/null +++ b/back-end/src/test/resources/application.properties @@ -0,0 +1,2 @@ +logging.config=classpath:logback-test.xml +spring.data.mongodb.port=0 diff --git a/back-end/src/test/resources/logback-test.xml b/back-end/src/test/resources/logback-test.xml new file mode 100644 index 0000000..e4a4f77 --- /dev/null +++ b/back-end/src/test/resources/logback-test.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/front-end/Dockerfile b/front-end/Dockerfile index d12ad56..43ef3b7 100644 --- a/front-end/Dockerfile +++ b/front-end/Dockerfile @@ -5,7 +5,7 @@ # © Copyright Utrecht University (Department of Information and Computing Sciences) # -FROM node:12 +FROM node:14 WORKDIR /app COPY package.json /app COPY package-lock.json /app diff --git a/front-end/README.md b/front-end/README.md index 04238c3..57727a9 100644 --- a/front-end/README.md +++ b/front-end/README.md @@ -4,7 +4,7 @@ This the front-end of the APE Web project. ## Requirements -To run APE Web you need to have [Node.js 12](https://nodejs.org) (or higher) installed on your system (use command `$ node --version` to check your local version). +To run APE Web you need to have [Node.js 14](https://nodejs.org) (or higher) installed on your system (use command `$ node --version` to check your local version). For instructions for Docker, please see the README file in the project root. @@ -12,10 +12,20 @@ For instructions for Docker, please see the README file in the project root. To setup up the APE Web front-end on your own machine, follow these steps: 1. In this directory, create a `.env.production.local` file (we recommend copying the provided `.env` file). -2. Fill in the configuration paremeters in this file. +2. Fill in the configuration parameters in this file. 3. In this directory, run `npm run build` to create a production build. 4. In this directory, run `npm run start` to start the webserver. **Note**: do not forget to also set up the back-end. The front-end cannot function without the back-end. Instructions on setting up the back-end can be found in the back-end's README. + +## Documentation + +The documentation of the front-end is generated using [TypeDoc](http://typedoc.org/). +To generate the documentation, use: +```shell +npm run typedoc +``` +You can now find the documentation in the `docs` directory. +Open `index.html` inside this directory to see the documentation. diff --git a/front-end/package-lock.json b/front-end/package-lock.json index 7d577de..4b1984e 100644 --- a/front-end/package-lock.json +++ b/front-end/package-lock.json @@ -1,6 +1,6 @@ { "name": "front-end", - "version": "1.0.0", + "version": "1.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/front-end/package.json b/front-end/package.json index 45dfc2a..b51dc5c 100644 --- a/front-end/package.json +++ b/front-end/package.json @@ -1,6 +1,6 @@ { "name": "front-end", - "version": "1.1.0", + "version": "1.2.0", "private": true, "scripts": { "dev": "next dev", diff --git a/front-end/src/components/Admin/PrivilegeManager.module.less b/front-end/src/components/Admin/PrivilegeManager.module.less new file mode 100644 index 0000000..29e40c1 --- /dev/null +++ b/front-end/src/components/Admin/PrivilegeManager.module.less @@ -0,0 +1,5 @@ +@import '../../styles/globals.less'; + +.nameSearchIcon { + color: @primary-color; +} diff --git a/front-end/src/components/Admin/PrivilegeManager.tsx b/front-end/src/components/Admin/PrivilegeManager.tsx new file mode 100644 index 0000000..d9122c6 --- /dev/null +++ b/front-end/src/components/Admin/PrivilegeManager.tsx @@ -0,0 +1,250 @@ +import React from 'react'; +import { Button, Input, Popconfirm, Space, Table, Tag, Tooltip } from 'antd'; +import { ColumnsType } from 'antd/lib/table'; +import { SearchOutlined } from '@ant-design/icons'; +import UserInfo from '@models/User'; +import { getSession } from 'next-auth/client'; +import styles from './PrivilegeManager.module.less'; + +interface PrivilegeManagerState { + /** The user of the current session. */ + currentUser: UserInfo, + /** The users in the table. */ + users: UserInfo[], +} + +/** + * Component for granting / revoking administrator privileges to / from users. + */ +class PrivilegeManager extends React.Component<{}, PrivilegeManagerState> { + /** React RefObject to refer to the user displayName search input */ + nameSearchRef: React.RefObject; + + /** + * Constructor + * @param props The props for the PrivilegeManager component. + */ + constructor(props: any) { + super(props); + + this.nameSearchRef = React.createRef(); + this.state = { + currentUser: null, + users: [], + }; + } + + componentDidMount() { + this.getUsers(); + } + + /** + * Get all approved users from the back-end (via an api proxy). + */ + getUsers = async () => { + const user: any = await getSession({}); + this.setState({ currentUser: user.user }); + + const usersEndpoint = `${process.env.NEXT_PUBLIC_FE_URL}/api/admin/user`; + await fetch(usersEndpoint, { + method: 'GET', + }) + .then((response) => response.json()) + .then((data) => { this.setState({ users: data }); }); + }; + + /** + * Add table keys to the user information objects. + * @param users The information of the users. + */ + addKeys = (info: UserInfo[]) => { + const result = []; + info.forEach((user, index) => { + result.push({ + // Add key + key: index.toString(), + // Add the user information + ...user, + }); + }); + return result; + }; + + /** + * Generate the properties to allow search on user names. + */ + getNameSearchProps = () => ({ + // Define the search filter dropdown + filterDropdown: ({ setSelectedKeys, selectedKeys, confirm, clearFilters }) => ( +
+ setSelectedKeys(e.target.value ? [e.target.value] : [])} + onPressEnter={() => confirm()} + style={{ width: 188, marginBottom: 8, display: 'block' }} + /> + + + + +
+ ), + // Highlight the filter icon when a search filter is applied + filterIcon: (filtered: boolean) => ( + + ), + // Filter rule + onFilter: (val: string, record: UserInfo) => ( + record.displayName.toString().toLowerCase().includes(val.toLowerCase()) + ), + // Select the search input after the search dropdown has opened + onFilterDropdownVisibleChange: (visible: boolean) => { + if (visible) { + setTimeout(() => this.nameSearchRef.current.select(), 100); + } + }, + }); + + /** + * Send the request to grant a user administrator privileges. + * @param userId The id of the user to grant the privileges to. + */ + grantUserAdmin = (userId: string) => { + const endpoint = `${process.env.NEXT_PUBLIC_FE_URL}/api/admin/adminstatus`; + fetch(endpoint, { + method: 'POST', + body: JSON.stringify({ userId, adminStatus: 'Active' }), + }) + .then(() => { this.getUsers(); }); + }; + + /** + * Send the request to revoke a user's administrator privileges. + * @param userId The id of the user to revoke the privileges from. + */ + revokeUserAdmin = (userId: string) => { + const endpoint = `${process.env.NEXT_PUBLIC_FE_URL}/api/admin/adminstatus`; + fetch(endpoint, { + method: 'POST', + body: JSON.stringify({ userId, adminStatus: 'Revoked' }), + }) + .then(() => { this.getUsers(); }); + }; + + /** + * Demote the current user after confirmation. + */ + revokeSelfConfirm = () => { + const { currentUser } = this.state; + this.revokeUserAdmin(currentUser.userId); + }; + + /** + * Get the columns definition for the table. + * @returns The columns definition. + */ + columns = (): ColumnsType => { + const { currentUser } = this.state; + + /* + * Normally, variables should be placed above the component's constructor + * However, this is impossible as getNameSearchProps would be defined below + * and we would get an "Property is used before its initialization" error. + */ + // eslint-disable-next-line react/sort-comp + return [ + { + title: 'Name', + dataIndex: 'displayName', + ...this.getNameSearchProps(), + }, + { + title: 'Role', + width: '25%', + render: (user: UserInfo) => ( +
+ {user.isAdmin + && Admin} + {!user.isAdmin + && User} + {currentUser.userId === user.userId + && You} +
+ ), + }, + { + title: 'Grant / Revoke privileges', + width: 250, + render: (user: UserInfo) => ( + + + + + + + + + + + ), + }, + ]; + }; + + render() { + const { users } = this.state; + + return ( + + ); + } +} + +export default PrivilegeManager; diff --git a/front-end/src/components/Domain/AccessManager/AccessManager.tsx b/front-end/src/components/Domain/AccessManager/AccessManager.tsx new file mode 100644 index 0000000..7b423da --- /dev/null +++ b/front-end/src/components/Domain/AccessManager/AccessManager.tsx @@ -0,0 +1,387 @@ +import React from 'react'; +import { Button, Form, Input, message, Modal, Progress, Select, Table, Tooltip } from 'antd'; +import Domain, { Access, UserWithAccess } from '@models/Domain'; +import UserSearch from '@components/UserSearch/UserSearch'; +import { UserAddOutlined } from '@ant-design/icons'; +import UserInfo from '@models/User'; +import { ColumnsType } from 'antd/lib/table'; +import { getSession } from 'next-auth/client'; + +const { Option } = Select; + +/** + * The props for the {@link AccessManager} component. + */ +interface AccessManagerProps { + /** The domain who's access to manage. */ + domain: Domain, + /** Callback function when the ownership is transferred to another user. */ + onOwnershipTransferred: (newOwner: UserInfo) => void, +} + +/** + * The state of the {@link AccessManager} component. + */ +interface AccessManagerState { + /** The user of the current session. */ + currentUser: UserInfo, + /** The users which have access to the domain (except: Owner, Revoked). */ + usersWithAccess: UserWithAccess[], + /** The currently selected access level for adding new users. */ + addUserSelectedRole: Access, + /** Whether the transfer ownership modal is opened. */ + transferOwnershipModalVisible: boolean, +} + +/** + * Component for managing access to a domain. + */ +class AccessManager extends React.Component { + /** React RefObject to refer to the UserSearch element. */ + userSearchRef: React.RefObject; + + constructor(props: AccessManagerProps) { + super(props); + + this.userSearchRef = React.createRef(); + this.state = { + currentUser: null, + usersWithAccess: [], + addUserSelectedRole: Access.Read, + transferOwnershipModalVisible: false, + }; + } + + async componentDidMount() { + // Get the current user from the session + await getSession({}).then((user: any) => this.setState({ currentUser: user.user })); + this.getUsersWithAccess(); + } + + /** + * Get all users with access to the domain. + */ + getUsersWithAccess = async () => { + const { domain } = this.props; + const accessLevels = ['Read', 'ReadWrite'].join('/'); + + const endpoint = `${process.env.NEXT_PUBLIC_FE_URL}/api/domain/access/${domain.id}/${accessLevels}`; + await fetch(endpoint, { + method: 'GET', + }) + .then((response) => response.json()) + .then((data) => this.setState({ usersWithAccess: data })); + }; + + /** + * Add table keys to the UserDomainAccess objects. + * @param userDomainAccessList The user domain access information array. + */ + addKeys = (userDomainAccessList: UserWithAccess[]) => { + const result = []; + userDomainAccessList.forEach((access, index) => { + result.push({ + // Add key + key: index.toString(), + // Add the access information + ...access, + }); + }); + return result; + }; + + /** + * Send the updated access level to the back-end. + * @param value The access level. + */ + updateAccessLevel = async (userId: string, value: Access) => { + const { domain } = this.props; + const endpoint = `${process.env.NEXT_PUBLIC_FE_URL}/api/domain/access/${domain.id}`; + await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ userId, access: value }), + }) + .then((response) => { + if (response.status !== 200) { + message.error('Failed to update the permission'); + } + }); + }; + + /** + * Revoke a user's access to the domain. + * @param userId The id of the user who's access is being revoked. + */ + onRevoke = (userId: string) => { + const { domain } = this.props; + const endpoint = `${process.env.NEXT_PUBLIC_FE_URL}/api/domain/access/${domain.id}`; + fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ userId, access: Access.Revoked }), + }) + .then((response) => { + if (response.status !== 200) { + message.error('Failed to revoke access'); + } + this.getUsersWithAccess(); + }); + }; + + /** + * Callback function for when the access level select for adding users changes. + * @param value The new access level. + */ + onAddUserRoleChange = (value: Access) => { + this.setState({ addUserSelectedRole: value }); + }; + + /** + * When a user is selected to be added, send a request to add the user. + * @param user The user who is being added. + */ + onAddUser = async (user: UserInfo) => { + const { addUserSelectedRole } = this.state; + await this.updateAccessLevel(user.userId, addUserSelectedRole); + this.getUsersWithAccess(); + this.userSearchRef.current.setValue(null); + }; + + /** + * Validate that the user that is added to the permissions table is not the owner of the domain. + * @param email The email address that is filled in. + * @returns True if the user may be added, false if the user may not be added. + */ + validateAddUser = (email: string): boolean => { + const { currentUser } = this.state; + + /* + * The current user is the owner, the owner may not downgrade his/her own permissions this way. + * Check if the current user is about to downgrade his/her own permissions. + * If so, validation fails. + */ + if (email === currentUser.email) { + message.warning('You cannot add yourself to the permissions list when you are the owner of the domain'); + return false; + } + return true; + }; + + /** + * Callback for the transfer ownership modal, when the domain ownership should be transferred. + * @param newOwner The new owner of the domain. + */ + onTransfer = (newOwner: UserInfo) => { + if (newOwner === null) { + message.info('Please select a user'); + return; + } + + const { domain, onOwnershipTransferred } = this.props; + const endpoint = `${process.env.NEXT_PUBLIC_FE_URL}/api/domain/transfer/${domain.id}`; + fetch(endpoint, { + method: 'POST', + body: newOwner.userId, + }) + .then((response) => response.json()) + .then((json: any) => { + switch (json.outcome) { + case 0: + message.info('Domain ownership has been transferred'); + this.setState({ transferOwnershipModalVisible: false }); + onOwnershipTransferred(newOwner); + break; + case 1: + message.error('You are not allowed to transfer the ownership of this domain'); + this.setState({ transferOwnershipModalVisible: false }); + break; + case 2: + message.error('Could not find the user to transfer ownership to'); + break; + default: + message.error('Failed to transfer ownership due to an unexpected error'); + throw Error('Failed to transfer ownership due to an unexpected error'); + } + }) + // eslint-disable-next-line no-console + .catch((e) => console.error(e)); + }; + + /** + * Generate a list of options for a Select for selecting access levels. + * @returns Options for a Select to select the access level. + */ + accessOptions = () => { + const options = []; + let ind = 0; + Object.keys(Access).forEach((key) => { + // Do not include the revoked and owner access level as a selectable option + if (key !== 'Revoked' && key !== 'Owner') { + ind += 1; + options.push(( + + )); + } + }); + return options; + }; + + /** + * Define the columns of the table. + */ + columns = (): ColumnsType => [ + { + title: 'Name', + dataIndex: 'userDisplayName', + // Allow sorting by username + sorter: (a: UserWithAccess, b: UserWithAccess) => { + if (a.userDisplayName < b.userDisplayName) { return -1; } + if (a.userDisplayName > b.userDisplayName) { return 1; } + return 0; + }, + sortDirections: ['ascend', 'descend'], + defaultSortOrder: 'ascend', + }, + { + title: 'Role', + width: 250, + render: (userWithAccess: UserWithAccess) => { + const options = this.accessOptions(); + + return ( + + ); + }, + }, + { + width: 100, + render: (userWithAccess: UserWithAccess) => ( + + + + ), + }, + ]; + + render() { + const { domain } = this.props; + const { usersWithAccess, transferOwnershipModalVisible } = this.state; + + /** + * Definition of the modal to transfer ownership. + */ + const TransferOwnershipModal = () => { + const [newOwner, setNewOwner] = React.useState(null); + + return ( + this.setState({ transferOwnershipModalVisible: false })} + onOk={() => this.onTransfer(newOwner)} + > +

+ You are about to transfer ownership of the domain to a different user. + You will still be able to use and edit the domain after the transfer, + but you will not be able to manage permissions anymore. +

+

Be sure this is what you want to do!

+
+ + + + + setNewOwner(user)} + /> + + + +
+ ); + }; + + return ( +
+ {/* The form is only being used for styling */} +
+ + + + + + } + onUserFound={this.onAddUser} + userValidation={this.validateAddUser} + ref={this.userSearchRef} + /> + + + + + + +
+ + + ); + } +} + +export default AccessManager; diff --git a/front-end/src/components/Domain/DomainList/DomainList.tsx b/front-end/src/components/Domain/DomainList/DomainList.tsx index 09d0193..993a76f 100644 --- a/front-end/src/components/Domain/DomainList/DomainList.tsx +++ b/front-end/src/components/Domain/DomainList/DomainList.tsx @@ -42,7 +42,7 @@ interface IState { * The different variations are set via the `edit`, `showVisibility`, and `showAccess` props. */ class DomainList extends React.Component { - /** React RefObject to ref to refer to the domain name search input */ + /** React RefObject to refer to the domain name search input */ titleSearchRef: React.RefObject; /** @@ -107,7 +107,7 @@ class DomainList extends React.Component { ), // Filter rule onFilter: (val, record) => record.title.toString().toLowerCase().includes(val.toLowerCase()), - // Select the search input after the search dropdown has openend + // Select the search input after the search dropdown has opened onFilterDropdownVisibleChange: (visible: boolean) => { if (visible) { setTimeout(() => this.titleSearchRef.current.select(), 100); diff --git a/front-end/src/components/UserSearch/UserSearch.tsx b/front-end/src/components/UserSearch/UserSearch.tsx new file mode 100644 index 0000000..575eb44 --- /dev/null +++ b/front-end/src/components/UserSearch/UserSearch.tsx @@ -0,0 +1,83 @@ +import React, { CSSProperties, ReactNode, Ref } from 'react'; +import { Input, message } from 'antd'; +import UserInfo from '@models/User'; + +const { Search } = Input; + +/** + * The props for the {@link UserSearch} component. + */ +interface UserSearchProps { + /** Placeholder text for the search field. */ + placeholder?: string, + /** + * Whether to show an enter button after input, or use another React node. + * See Ant Design's documentation on Input.Search: https://ant.design/components/input/#Input.Search. + */ + enterButton?: boolean | ReactNode, + /** Callback function when a user is found. */ + onUserFound?: (user: UserInfo) => void, + /** + * Validate if a user may be selected. + * Return false if a user may not be selected, or true if a user may be selected. + */ + userValidation?: (email: string) => boolean, + /** CSS styling for the internal Search component. */ + style?: CSSProperties, +} + +/** + * Component to search users by their e-mail address. + */ +const UserSearch = React.forwardRef((props: UserSearchProps, ref: Ref) => { + /** + * Search the user with the given e-mail address. + * @param mail The e-mail address to search by. + */ + const searchUser = (mail: string) => { + const { userValidation } = props; + // If the user validation fails, don't continue. + if (userValidation(mail) === false) { + return; + } + + const { onUserFound } = props; + + const endpoint = `${process.env.NEXT_PUBLIC_FE_URL}/api/user/email/${mail}`; + fetch(endpoint, { + method: 'GET', + }) + .then((response) => response.json()) + .then((data) => { + if (data.found === false) { + message.error('Could not find a user with the given e-mail address'); + return; + } + onUserFound(data); + }); + }; + + const { placeholder, enterButton, style } = props; + + return ( + + ); +}); + +// Default values for UserSearchProps. +UserSearch.defaultProps = { + placeholder: 'Search user by their email address', + enterButton: false, + onUserFound: () => {}, + userValidation: () => true, + style: null, +}; + +export default UserSearch; diff --git a/front-end/src/models/Domain.tsx b/front-end/src/models/Domain.tsx index 3cb7d3f..878b926 100644 --- a/front-end/src/models/Domain.tsx +++ b/front-end/src/models/Domain.tsx @@ -61,3 +61,30 @@ export enum Access { ReadWrite = 'ReadWrite', Revoked = 'Revoked' } + +/** + * An object received from the back-end when asked for all users with access to a domain. + * The relevant endpoint: `/api/domain/users-with-access/{id}`. + */ +export interface UserWithAccess { + /** The id of the UserDomainAccess object */ + id: string, + /** The id of the user who has access. */ + userId: string, + /** The display name of the user who has access. */ + userDisplayName: string, + /** The id of the domain the user has access to. */ + domainId: string, + /** The access level the user has to the domain. */ + accessRight: Access, +} + +/** + * Object to send user access to domain updates. + */ +export interface UserAccessUpload { + /** The id of the user who gains access. */ + userId: string, + /** The access level the user will get to the domain. */ + access: Access, +} diff --git a/front-end/src/models/User.tsx b/front-end/src/models/User.tsx new file mode 100644 index 0000000..998eb2d --- /dev/null +++ b/front-end/src/models/User.tsx @@ -0,0 +1,30 @@ +/** + * A user information received from the back-end. + */ +export default interface UserInfo { + /** The id of the user. */ + userId: string, + /** + * The email address of the user. + * It might be null when the back-end hides it for privacy reasons. + */ + email: string | null, + /** The display name of the user. */ + displayName: string, + /** The status of the user account. */ + status: UserStatus, + /** Whether the user is an administrator. */ + isAdmin: boolean, +} + +/** + * The account status of a user. + */ +export enum UserStatus { + /** The user account is approved. */ + Approved = 'Approved', + /** The user account is pending approval. */ + Pending = 'Pending', + /** The user account has been denied. */ + Denied = 'Denied' +} diff --git a/front-end/src/pages/about.tsx b/front-end/src/pages/about.tsx index 1e2a30d..2cdd56d 100644 --- a/front-end/src/pages/about.tsx +++ b/front-end/src/pages/about.tsx @@ -25,7 +25,7 @@ function AboutPage() { About us - APE Web View is a graphical interface for the APE library. + APE Web is a graphical interface for the APE library. APE (Automated Pipeline Explorer) is a command line tool and Java API for the automated exploration of possible computational pipelines (scientific workflows) from large collections of computational tools. diff --git a/front-end/src/pages/admin.tsx b/front-end/src/pages/admin.tsx index 71466d3..3a8408b 100644 --- a/front-end/src/pages/admin.tsx +++ b/front-end/src/pages/admin.tsx @@ -14,6 +14,7 @@ import { getSession, signIn } from 'next-auth/client'; import TopicCreate from '@components/Admin/TopicCreate'; import RunParametersConfig from '@components/Admin/RunParametersConfig'; import { RunOptions } from '@models/workflow/Workflow'; +import PrivilegeManager from '@components/Admin/PrivilegeManager'; import styles from './Admin.module.less'; const { Title } = Typography; @@ -58,7 +59,7 @@ class AdminPage extends React.Component { motivation: value.motivation, })); - constructor(props) { + constructor(props: IProps) { super(props); // Initial state of the component: empty list @@ -146,6 +147,10 @@ class AdminPage extends React.Component { Run parameters configuration +
+ Admin privilege management + +
); } diff --git a/front-end/src/pages/api/admin/adminstatus.tsx b/front-end/src/pages/api/admin/adminstatus.tsx new file mode 100644 index 0000000..2e19a56 --- /dev/null +++ b/front-end/src/pages/api/admin/adminstatus.tsx @@ -0,0 +1,23 @@ +import { getSession } from 'next-auth/client'; + +/** + * Handle incoming requests. + * @param req The incoming request. + * @param res The outgoing response. + */ +export default async function handler(req: any, res: any) { + const session: any = await getSession({ req }); + const { body } = req; + + const endpoint = `${process.env.NEXT_PUBLIC_BASE_URL_NODE}/api/admin/adminstatus`; + await fetch(endpoint, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + cookie: session.user.sessionid, + }, + body, + }) + .then((response) => { res.status(response.status).end(); }); +} diff --git a/front-end/src/pages/api/admin/user/index.tsx b/front-end/src/pages/api/admin/user/index.tsx new file mode 100644 index 0000000..7d1f77e --- /dev/null +++ b/front-end/src/pages/api/admin/user/index.tsx @@ -0,0 +1,43 @@ +import { getSession } from 'next-auth/client'; +import UserInfo from '@models/User'; + +/** + * Handle GET requests. + * @param res The outgoing response. + * @param session The current session. + */ +async function handleGET(res: any, session: any) { + let users: UserInfo[]; + + const endpoint: string = `${process.env.NEXT_PUBLIC_BASE_URL_NODE}/user/`; + await fetch(endpoint, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + cookie: session.user.sessionid, + }, + }) + .then((response) => response.json()) + .then((data) => { users = data; }); + + return res.status(200).json(users); +} + +/** + * Handle incoming requests. + * @param req The incoming request. + * @param res The outgoing response. + */ +export default async function handler(req: any, res: any) { + const session: any = await getSession({ req }); + const { method } = req; + + switch (method) { + case 'GET': + return handleGET(res, session); + default: + res.setHeader('Allow', ['GET']); + return res.status(405).end(`Method ${method} Not Allowed`); + } +} diff --git a/front-end/src/pages/api/domain/access/[...slug].tsx b/front-end/src/pages/api/domain/access/[...slug].tsx new file mode 100644 index 0000000..1506e60 --- /dev/null +++ b/front-end/src/pages/api/domain/access/[...slug].tsx @@ -0,0 +1,85 @@ +import { UserAccessUpload, UserWithAccess } from '@models/Domain'; +import { getSession } from 'next-auth/client'; + +/** + * GET the users with access to the domain and their access levels. + * @param res The outgoing response. + * @param session The current session. + * @param domainId The id of the domain to get users with access of it. + * @param accessLevels The access levels the users may have. + * @returns A response to the caller of this api endpoint. + */ +async function handleGET(res: any, session: any, domainId: string, accessLevels: any) { + let result: UserWithAccess[] | number; + + const accessRights = accessLevels.join(','); + const endpoint = `${process.env.NEXT_PUBLIC_BASE_URL_NODE}/api/domain/users-with-access/${domainId}?accessRights=${accessRights}`; + await fetch(endpoint, { + method: 'GET', + credentials: 'include', + headers: { + cookie: session.user.sessionid, + }, + }) + .then((response) => { + if (response.status !== 200) { + return response.status; + } + return response.json(); + }) + .then((data) => { result = data; }); + + // If an error occurred, return the HTTP status code. + if (typeof result === 'number') { + return res.status(result).end(); + } + return res.status(200).json(result); +} + +/** + * POST the access right of a user to the back-end. + * @param res The outgoing response. + * @param session The current session. + * @param domainId The id of the domain to which rights are given. + * @param userAccess The user and access level. + */ +async function handlePOST(res: any, session: any, domainId: string, userAccess: UserAccessUpload) { + let result: number; + + const endpoint = `${process.env.NEXT_PUBLIC_BASE_URL_NODE}/api/domain/${domainId}/access`; + await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + cookie: session.user.sessionid, + }, + body: JSON.stringify(userAccess), + }) + .then((response) => { result = response.status; }); + + return res.status(result).end(); +} + +/** + * Handle incoming requests. + * @param req The incoming request. + * @param res The outgoing response. + * @returns A response to the caller of this api endpoint. + */ +export default async function handler(req: any, res: any) { + const session: any = await getSession({ req }); + const { method } = req; + const { slug } = req.query; + + switch (method) { + case 'GET': + return handleGET(res, session, slug[0], slug.slice(1, slug.length)); + case 'POST': { + const { body } = req; + return handlePOST(res, session, slug[0], body); + } + default: + res.setHeader('Allow', ['GET', 'POST']); + return res.status(405).end(`Method ${method} Not Allowed`); + } +} diff --git a/front-end/src/pages/api/domain/transfer/[id].tsx b/front-end/src/pages/api/domain/transfer/[id].tsx new file mode 100644 index 0000000..4a99fb1 --- /dev/null +++ b/front-end/src/pages/api/domain/transfer/[id].tsx @@ -0,0 +1,44 @@ +import { getSession } from 'next-auth/client'; + +/** + * Handle incoming requests. + * @param req The incoming request. + * @param res The outgoing response. + */ +export default async function handler(req: any, res: any) { + const session: any = await getSession({ req }); + const { id } = req.query; + const { body } = req; + + const endpoint = `${process.env.NEXT_PUBLIC_BASE_URL_NODE}/api/domain/${id}/transfer/${body}`; + await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + cookie: session.user.sessionid, + }, + }) + .then((response) => { + switch (response.status) { + case 200: + // Ownership transferred + res.status(200).json({ outcome: 0 }); + break; + case 403: + // User is not allowed to transfer the domain ownership. + res.status(200).json({ outcome: 1 }); + break; + case 400: + /* + * User to transfer ownership to was not found, or the domain was not found + * (but this is highly unlikely because the domain id was handled automatically). + */ + res.status(200).json({ outcome: 2 }); + break; + default: + res.status(response.status).json(); + break; + } + }) + .catch(() => res.status(500).end()); +} diff --git a/front-end/src/pages/api/user/email/[email].tsx b/front-end/src/pages/api/user/email/[email].tsx new file mode 100644 index 0000000..aa58646 --- /dev/null +++ b/front-end/src/pages/api/user/email/[email].tsx @@ -0,0 +1,46 @@ +import { getSession } from 'next-auth/client'; + +/** + * Handle GET requests. + * @param req The incoming request. + * @param res The outgoing response. + * @param session The current session. + */ +async function handleGET(req: any, res: any, session: any) { + const { email } = req.query; + const endpoint: string = `${process.env.NEXT_PUBLIC_BASE_URL_NODE}/user/email/${email}`; + await fetch(endpoint, { + method: 'GET', + credentials: 'include', + headers: { + cookie: session.user.sessionid, + }, + }) + .then(async (response) => { + if (!response.ok) { + const message = await response.json().then((e) => e.message); + throw new Error(message); + } + return response.json(); + }) + .then((data) => res.status(200).json(data)) + .catch(() => res.status(200).json({ found: false })); +} + +/** + * Handle incoming requests. + * @param req The incoming request. + * @param res The outgoing response. + */ +export default async function handler(req: any, res: any) { + const session: any = await getSession({ req }); + const { method } = req; + + switch (method) { + case 'GET': + return handleGET(req, res, session); + default: + res.setHeader('Allow', ['GET']); + return res.status(405).end(`Method ${method} Not Allowed`); + } +} diff --git a/front-end/src/pages/contact.tsx b/front-end/src/pages/contact.tsx index 6d5ccb6..c69fba6 100644 --- a/front-end/src/pages/contact.tsx +++ b/front-end/src/pages/contact.tsx @@ -26,7 +26,7 @@ function ContactPage() { Contact   - For any questions concerning APE and APE Web View you can get in touch with: + For any questions concerning APE and APE Web you can get in touch with:
  • Vedran Kasalica (v.kasalica[at]uu.nl), lead developer
  • diff --git a/front-end/src/pages/domain/edit/[id].tsx b/front-end/src/pages/domain/edit/[id].tsx index f45ddab..c6d8f45 100644 --- a/front-end/src/pages/domain/edit/[id].tsx +++ b/front-end/src/pages/domain/edit/[id].tsx @@ -8,22 +8,32 @@ import React from 'react'; import Head from 'next/head'; import { useRouter } from 'next/router'; -import { Button, Result } from 'antd'; +import { Button, Result, Typography } from 'antd'; import DomainEdit from '@components/Domain/DomainEdit/DomainEdit'; -import Domain, { Topic } from '@models/Domain'; +import Domain, { Topic, UserWithAccess } from '@models/Domain'; import { getSession } from 'next-auth/client'; import { fetchTopics } from '@components/Domain/Domain'; +import AccessManager from '@components/Domain/AccessManager/AccessManager'; import styles from './[id].module.less'; +const { Title } = Typography; + /** * Props for DomainEditPage */ interface IDomainEditPageProps { + /** The ID of the current user. */ + userId: string, /** The ID of the domain to edit */ domain: Domain; + /** The topics of the domain. */ topics: Topic[]; + /** Whether the domain was found. */ notFound: boolean; + /** Whether the user has access to the domain. */ access: boolean; + /** Whether the user is the owner of the domain. */ + isOwner: boolean; } /** @@ -57,6 +67,33 @@ async function hasAccess(user, domainID: string): Promise { return access; } +/** + * Check whether a user is the owner of a domain. + * @param user The user who we check to be the owner. + * @param domainId The domain to check ownership of. + * @returns True if the user is the owner, false if the user is not the owner. + */ +async function checkOwnership(user, domainId: string): Promise { + const endpoint = `${process.env.NEXT_PUBLIC_BASE_URL_NODE}/api/domain/users-with-access/${domainId}?accessRights=Owner`; + return fetch(endpoint, { + method: 'GET', + headers: { + cookie: user.sessionid, + }, + }) + .then((response) => response.json()) + .then((data: UserWithAccess[]) => { + let owner = false; + data.forEach((u: UserWithAccess) => { + if (u.userId === user.userId) { + owner = true; + } + }); + return owner; + }) + .catch(() => false); +} + /** * Fetch a domain from the back-end. * @param user The user information, with the sessionid. @@ -82,27 +119,41 @@ async function fetchDomain(user: any, id: string): Promise { /** * Page for editing domains, built around the {@link DomainEdit} component. * - * Includes result pages to be shown in case of erorrs. + * Includes result pages to be shown in case of errors. */ -function DomainEditPage({ domain, topics, notFound, access }: IDomainEditPageProps) { +function DomainEditPage(props: IDomainEditPageProps) { const router = useRouter(); + const { userId, domain, topics, notFound, access, isOwner: isOwnerInitial } = props; + const [isOwner, setIsOwner] = React.useState(isOwnerInitial); return (
    { /* * Make sure DomainEdit is not rendered before data is loaded. - * Otherwise, Ant Desing's Form initialValues does not work. + * Otherwise, Ant Design's Form initialValues does not work. */ domain !== null && topics !== [] && access && (
    Edit {domain.title} | APE + Domain + { + isOwner && ( +
    + Permissions + setIsOwner(newOwner.userId === userId)} + /> +
    + ) + }
    ) } @@ -122,13 +173,13 @@ function DomainEditPage({ domain, topics, notFound, access }: IDomainEditPagePro ) } { - // Show unauthorized result when the user doesn not have access + // Show unauthorized result when the user doesn't not have access !access && ( Return to my domains} + extra={} /> ) } @@ -137,29 +188,35 @@ function DomainEditPage({ domain, topics, notFound, access }: IDomainEditPagePro } export async function getServerSideProps({ query, req }) { - // Get the domainID fromt the url parameters + // Get the domainID from the url parameters const session = await getSession({ req }); - let access; + let access: boolean = false; + let owner: boolean = false; let notFound = false; let domain = null; let topics = []; - await hasAccess(session.user, query.id).then((acc) => { access = acc; }); - if (access) { - await fetchDomain(session.user, query.id) - .then((d) => { - // Domain not found, update state - if (d === null) { - notFound = true; - return; - } - domain = d; - }); - // Get all topics - await fetchTopics(session.user, true) - .then((t) => { topics = t; }); + + if (session !== null) { + await hasAccess(session.user, query.id).then((acc) => { access = acc; }); + if (access) { + await fetchDomain(session.user, query.id) + .then((d) => { + // Domain not found, update state + if (d === null) { + notFound = true; + return; + } + domain = d; + }); + // Get all topics + await fetchTopics(session.user, true) + .then((t) => { topics = t; }); + await checkOwnership(session.user, query.id) + .then((o) => { owner = o; }); + } } return { - props: { access, notFound, domain, topics }, + props: { access, isOwner: owner, notFound, domain, topics }, }; } diff --git a/front-end/src/pages/index.tsx b/front-end/src/pages/index.tsx index 5f9288a..e1a634e 100644 --- a/front-end/src/pages/index.tsx +++ b/front-end/src/pages/index.tsx @@ -73,10 +73,10 @@ function DomainsPage({ publicDomains, ownedDomains, sharedDomains, session }: ID Home | APE
    - Welcome to APE Web View + Welcome to APE Web   - APE (Automated Pipeline Explorer) Web View is a graphical interface for the  + APE (Automated Pipeline Explorer) Web is a graphical interface for the  APE library  (see GitHub), used for the automated exploration of possible computational pipelines @@ -93,7 +93,7 @@ function DomainsPage({ publicDomains, ownedDomains, sharedDomains, session }: ID our page. - APE Web View allows you to explore and automatically compose + APE Web allows you to explore and automatically compose these workflows from pre-defined domains (such as image manipulation domain using the ImageMagick toolset). In addition you are encouraged to create your own domains and share them with other users. diff --git a/front-end/src/pages/privacy.tsx b/front-end/src/pages/privacy.tsx index d22683a..ca295e4 100644 --- a/front-end/src/pages/privacy.tsx +++ b/front-end/src/pages/privacy.tsx @@ -23,12 +23,12 @@ function PrivacyPage() {
- APE Web View Privacy Policy + APE Web Privacy Policy - At APE WEb View, accessible at ape.science.uu.nl, + At APE Web, accessible at ape.science.uu.nl, one of our main priorities is the privacy of our visitors. This Privacy Policy document contains types of information - that is collected and recorded by the APE Web View website and how we use it. + that is collected and recorded by the APE Web website and how we use it. If you have additional questions or require more information about our Privacy Policy, @@ -38,7 +38,7 @@ function PrivacyPage() { This privacy policy applies only to our online activities and is valid for visitors to our website with regards to the information that they shared and/or - collect in APE Web View. + collect in APE Web. This policy is not applicable to any information collected offline or via channels other than this website.