From 9fdf15c9855ffaa6500c96edc8c23730bed42f5c Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Sun, 18 Dec 2022 16:01:01 +0100 Subject: [PATCH 1/9] 665: Add createAdministrator interface --- .../backend/auth/database/Schema.kt | 12 ++++++ .../repos/AdministratorsRepository.kt | 34 +++++++++------ .../backend/auth/service/Authorizer.kt | 23 ++++++++++ .../auth/webservice/authGraphQLParams.kt | 4 +- .../schema/ManageUsersMutationService.kt | 43 +++++++++++++++++++ specs/backend-api.graphql | 2 + 6 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ManageUsersMutationService.kt diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/Schema.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/Schema.kt index d55a03a68..5d3ea798d 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/Schema.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/Schema.kt @@ -1,12 +1,15 @@ package app.ehrenamtskarte.backend.auth.database +import app.ehrenamtskarte.backend.auth.webservice.schema.types.Role import app.ehrenamtskarte.backend.projects.database.Projects import app.ehrenamtskarte.backend.regions.database.Regions import org.jetbrains.exposed.dao.IntEntity import org.jetbrains.exposed.dao.IntEntityClass import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.javatime.datetime +import org.jetbrains.exposed.sql.or object Administrators : IntIdTable() { val email = varchar("email", 100).uniqueIndex() @@ -16,6 +19,15 @@ object Administrators : IntIdTable() { val passwordHash = binary("passwordHash").nullable() val passwordResetKey = varchar("passwordResetKey", 100).nullable() val passwordResetKeyExpiry = datetime("passwordResetKeyExpiry").nullable() + + init { + val noRegionCompatibleRoles = listOf(Role.PROJECT_ADMIN, Role.NO_RIGHTS) + val regionCompatibleRoles = listOf(Role.REGION_MANAGER, Role.REGION_ADMIN, Role.NO_RIGHTS) + check("roleRegionCombinationConstraint") { + regionId.isNull().and(role.inList(noRegionCompatibleRoles.map { it.db_value })) or + regionId.isNotNull().and(role.inList(regionCompatibleRoles.map { it.db_value })) + } + } } class AdministratorEntity(id: EntityID) : IntEntity(id) { diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt index 0eedfad5b..ebfe9c999 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt @@ -10,8 +10,7 @@ import app.ehrenamtskarte.backend.auth.webservice.schema.types.Role import app.ehrenamtskarte.backend.common.database.sortByKeys import app.ehrenamtskarte.backend.projects.database.ProjectEntity import app.ehrenamtskarte.backend.projects.database.Projects -import app.ehrenamtskarte.backend.regions.database.Regions -import org.jetbrains.exposed.dao.id.EntityID +import app.ehrenamtskarte.backend.regions.database.RegionEntity import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.select @@ -42,26 +41,35 @@ object AdministratorsRepository { } } - fun insert(project: String, email: String, password: String, role: Role, regionId: Int? = null) { - val projectId = ProjectEntity.find { Projects.project eq project }.firstOrNull()?.id + fun insert(project: String, email: String, password: String?, role: Role, regionId: Int? = null) { + val projectEntity = ProjectEntity.find { Projects.project eq project }.firstOrNull() ?: throw IllegalArgumentException("Project does not exist.") - if (role in setOf(Role.REGION_ADMIN, Role.REGION_MANAGER) && regionId == null) { + val region = regionId?.let { RegionEntity.findById(regionId) } + + if (region != null && region.projectId != projectEntity.id) { + throw IllegalArgumentException("Specified region is not part of specified project.") + } + + if (role in setOf(Role.REGION_ADMIN, Role.REGION_MANAGER) && region == null) { throw IllegalArgumentException("Role ${role.db_value} needs to have a region assigned.") - } else if (role in setOf(Role.PROJECT_ADMIN) && regionId != null) { - throw java.lang.IllegalArgumentException("Role ${role.db_value} cannot have a region assigned.") + } else if (role in setOf(Role.PROJECT_ADMIN) && region != null) { + throw IllegalArgumentException("Role ${role.db_value} cannot have a region assigned.") } - val passwordValidation = PasswordValidator.validatePassword(password) - if (passwordValidation != PasswordValidationResult.VALID) { - throw InvalidPasswordException(passwordValidation) + val passwordHash = password?.let { + val passwordValidation = PasswordValidator.validatePassword(it) + if (passwordValidation != PasswordValidationResult.VALID) { + throw InvalidPasswordException(passwordValidation) + } + PasswordCrypto.hashPasswort(it) } AdministratorEntity.new { this.email = email - this.projectId = projectId - this.regionId = if (regionId != null) EntityID(regionId, Regions) else null - this.passwordHash = PasswordCrypto.hashPasswort(password) + this.projectId = projectEntity.id + this.regionId = region?.id + this.passwordHash = passwordHash this.role = role.db_value } } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/service/Authorizer.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/service/Authorizer.kt index 375f84348..6cc2101b5 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/service/Authorizer.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/service/Authorizer.kt @@ -39,4 +39,27 @@ object Authorizer { return (user?.role == Role.REGION_ADMIN.db_value && user.regionId == region.id) || mayViewUsersInProject(user, region.projectId.value) } + + fun mayCreateAdministrator( + actingAdmin: AdministratorEntity, + newAdminProjectId: Int, + newAdminRole: Role, + newAdminRegion: RegionEntity? + ): Boolean { + if (actingAdmin.projectId.value != newAdminProjectId) { + return false + } else if (newAdminRole == Role.NO_RIGHTS) { + return false + } + + if (actingAdmin.role == Role.PROJECT_ADMIN.db_value) { + return true + } else if ( + actingAdmin.role == Role.REGION_ADMIN.db_value && + newAdminRegion != null && actingAdmin.regionId == newAdminRegion.id + ) { + return true + } + return false + } } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/authGraphQLParams.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/authGraphQLParams.kt index 068c87c44..ed468ca2a 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/authGraphQLParams.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/authGraphQLParams.kt @@ -3,6 +3,7 @@ package app.ehrenamtskarte.backend.auth.webservice import app.ehrenamtskarte.backend.auth.webservice.dataloader.ADMINISTRATOR_LOADER_NAME import app.ehrenamtskarte.backend.auth.webservice.dataloader.administratorLoader import app.ehrenamtskarte.backend.auth.webservice.schema.ChangePasswordMutationService +import app.ehrenamtskarte.backend.auth.webservice.schema.ManageUsersMutationService import app.ehrenamtskarte.backend.auth.webservice.schema.ResetPasswordMutationService import app.ehrenamtskarte.backend.auth.webservice.schema.SignInMutationService import app.ehrenamtskarte.backend.auth.webservice.schema.ViewAdministratorsQueryService @@ -23,7 +24,8 @@ val authGraphQlParams = GraphQLParams( mutations = listOf( TopLevelObject(SignInMutationService()), TopLevelObject(ChangePasswordMutationService()), - TopLevelObject(ResetPasswordMutationService()) + TopLevelObject(ResetPasswordMutationService()), + TopLevelObject(ManageUsersMutationService()) ), queries = listOf( TopLevelObject(ViewAdministratorsQueryService()) diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ManageUsersMutationService.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ManageUsersMutationService.kt new file mode 100644 index 000000000..3dcdc5d94 --- /dev/null +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ManageUsersMutationService.kt @@ -0,0 +1,43 @@ +package app.ehrenamtskarte.backend.auth.webservice.schema + +import app.ehrenamtskarte.backend.auth.database.AdministratorEntity +import app.ehrenamtskarte.backend.auth.database.repos.AdministratorsRepository +import app.ehrenamtskarte.backend.auth.service.Authorizer +import app.ehrenamtskarte.backend.auth.webservice.schema.types.Role +import app.ehrenamtskarte.backend.common.webservice.GraphQLContext +import app.ehrenamtskarte.backend.common.webservice.UnauthorizedException +import app.ehrenamtskarte.backend.projects.database.ProjectEntity +import app.ehrenamtskarte.backend.projects.database.Projects +import app.ehrenamtskarte.backend.regions.database.RegionEntity +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import graphql.schema.DataFetchingEnvironment +import org.jetbrains.exposed.sql.transactions.transaction + +@Suppress("unused") +class ManageUsersMutationService { + + @GraphQLDescription("Creates a new administrator") + fun createAdministrator( + project: String, + email: String, + role: Role, + regionId: Int?, + dfe: DataFetchingEnvironment + ): Boolean { + val context = dfe.getContext() + val jwtPayload = context.enforceSignedIn() + transaction { + val actingAdmin = AdministratorEntity.findById(jwtPayload.userId) ?: throw UnauthorizedException() + + val projectEntity = ProjectEntity.find { Projects.project eq project }.first() + val region = regionId?.let { RegionEntity.findById(it) } + + if (!Authorizer.mayCreateAdministrator(actingAdmin, projectEntity.id.value, role, region)) { + throw UnauthorizedException() + } + + AdministratorsRepository.insert(project, email, null, role, regionId) + } + return true + } +} diff --git a/specs/backend-api.graphql b/specs/backend-api.graphql index 2a09da921..498e6dbe1 100644 --- a/specs/backend-api.graphql +++ b/specs/backend-api.graphql @@ -86,6 +86,8 @@ type Mutation { addGoldenEakApplication(application: GoldenCardApplicationInput!, regionId: Int!): Boolean! "Changes an administrator's password" changePassword(currentPassword: String!, email: String!, newPassword: String!, project: String!): Boolean! + "Creates a new administrator" + createAdministrator(email: String!, project: String!, regionId: Int, role: Role!): Boolean! "Deletes the application with specified id" deleteApplication(applicationId: Int!): Boolean! "Reset the administrator's password" From 509b8533624d765d55383c1cfdefc9b1c835b75d Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Sun, 18 Dec 2022 19:46:46 +0100 Subject: [PATCH 2/9] 665: Add createAdministrator dialog --- .../src/components/RegionSelector.tsx | 62 +++++++--- .../auth/ForgotPasswordController.tsx | 2 +- .../auth/ResetPasswordController.tsx | 2 +- .../src/components/users/CreateUserDialog.tsx | 113 ++++++++++++++++++ .../users/ManageUsersController.tsx | 4 +- .../src/components/users/RoleHelpButton.tsx | 42 +++++++ .../src/components/users/UsersTable.tsx | 16 ++- .../graphql/users/createAdministrator.graphql | 3 + 8 files changed, 218 insertions(+), 26 deletions(-) create mode 100644 administration/src/components/users/CreateUserDialog.tsx create mode 100644 administration/src/components/users/RoleHelpButton.tsx create mode 100644 administration/src/graphql/users/createAdministrator.graphql diff --git a/administration/src/components/RegionSelector.tsx b/administration/src/components/RegionSelector.tsx index edeab5c65..7a9bb170c 100644 --- a/administration/src/components/RegionSelector.tsx +++ b/administration/src/components/RegionSelector.tsx @@ -1,6 +1,6 @@ -import React, { useContext } from 'react' -import { Button, Menu, MenuItem, Spinner } from '@blueprintjs/core' -import { ItemListRenderer, ItemRenderer, Select } from '@blueprintjs/select' +import React, { useContext, useMemo } from 'react' +import { Button, Menu, Spinner } from '@blueprintjs/core' +import { Classes, ItemListRenderer, ItemRenderer, Select } from '@blueprintjs/select' import { Region, useGetRegionsQuery } from '../generated/graphql' import { ProjectConfigContext } from '../project-configs/ProjectConfigContext' @@ -8,8 +8,8 @@ const RegionSelect = Select.ofType() const getTitle = (region: Region) => `${region.prefix} ${region.name}` -const renderMenu: ItemListRenderer = ({ items, itemsParentRef, renderItem }) => { - const renderedItems = items.map(renderItem).filter(item => item != null) +const renderMenu: ItemListRenderer = ({ itemsParentRef, renderItem, filteredItems }) => { + const renderedItems = filteredItems.map(renderItem).filter(item => item != null) return ( {renderedItems} @@ -19,36 +19,60 @@ const renderMenu: ItemListRenderer = ({ items, itemsParentRef, renderIte const itemRenderer: ItemRenderer = (region, { handleClick, modifiers }) => { return ( - + active={modifiers.active} + disabled={modifiers.disabled}> + {getTitle(region)} + ) } -const RegionSelector = (props: { onRegionSelect: (region: Region) => void; activeRegionId: number }) => { +const RegionSelector = (props: { onSelect: (region: Region) => void; selectedId: number | null }) => { const projectId = useContext(ProjectConfigContext).projectId const { loading, error, data, refetch } = useGetRegionsQuery({ variables: { project: projectId }, }) + const regions = useMemo(() => (data ? [...data.regions].sort((a, b) => a.name.localeCompare(b.name)) : null), [data]) if (loading) return - if (error || !data) return + ) } diff --git a/administration/src/components/auth/ForgotPasswordController.tsx b/administration/src/components/auth/ForgotPasswordController.tsx index fba5d62ca..00a7bc936 100644 --- a/administration/src/components/auth/ForgotPasswordController.tsx +++ b/administration/src/components/auth/ForgotPasswordController.tsx @@ -54,7 +54,7 @@ const ForgotPasswordController = () => { e.preventDefault() submit() }}> - + setEmail(e.target.value)} diff --git a/administration/src/components/auth/ResetPasswordController.tsx b/administration/src/components/auth/ResetPasswordController.tsx index 9d1f55a36..f07bece6a 100644 --- a/administration/src/components/auth/ResetPasswordController.tsx +++ b/administration/src/components/auth/ResetPasswordController.tsx @@ -57,7 +57,7 @@ const ResetPasswordController = () => { e.preventDefault() submit() }}> - + setEmail(e.target.value)} diff --git a/administration/src/components/users/CreateUserDialog.tsx b/administration/src/components/users/CreateUserDialog.tsx new file mode 100644 index 000000000..d42b4b5f4 --- /dev/null +++ b/administration/src/components/users/CreateUserDialog.tsx @@ -0,0 +1,113 @@ +import { Button, Classes, Dialog, FormGroup, HTMLSelect, InputGroup } from '@blueprintjs/core' +import { useContext, useState } from 'react' +import styled from 'styled-components' +import { Role, useCreateAdministratorMutation } from '../../generated/graphql' +import { useAppToaster } from '../AppToaster' +import RoleHelpButton from './RoleHelpButton' +import { roleToText } from './UsersTable' +import { ProjectConfigContext } from '../../project-configs/ProjectConfigContext' +import RegionSelector from '../RegionSelector' + +const RoleFormGroupLabel = styled.span` + & span { + display: inline-block !important; + } +` + +const RoleSelector = ({ role, onChange }: { role: Role | null; onChange: (role: Role | null) => void }) => { + return ( + onChange((e.target.value as Role | null) ?? null)} value={role ?? ''} required> + + + + + + ) +} + +const CreateUserDialog = ({ + isOpen, + onClose, + onSuccess, +}: { + isOpen: boolean + onClose: () => void + onSuccess: () => void +}) => { + const appToaster = useAppToaster() + const [email, setEmail] = useState('') + const [role, setRole] = useState(null) + const [regionId, setRegionId] = useState(null) + const project = useContext(ProjectConfigContext).projectId + const rolesWithRegion = [Role.RegionManager, Role.RegionAdmin] + + const [createAdministrator, { loading }] = useCreateAdministratorMutation({ + onError: error => { + console.error(error) + appToaster?.show({ intent: 'danger', message: 'Es ist etwas schief gelaufen.' }) + }, + onCompleted: () => { + appToaster?.show({ intent: 'success', message: 'Verwalter erfolgreich erstellt.' }) + onClose() + onSuccess() + // Reset State + setEmail('') + setRole(null) + setRegionId(null) + }, + }) + + return ( + +
{ + e.preventDefault() + createAdministrator({ + variables: { + project, + email, + role: role as Role, + regionId: role !== null && rolesWithRegion.includes(role) ? regionId : null, + }, + }) + }}> +
+ + setEmail(e.target.value)} + type='email' + placeholder='erika.musterfrau@example.org' + /> + + + Rolle + + }> + + + {role === null || !rolesWithRegion.includes(role) ? null : ( +
+
Region
+
+ setRegionId(region.id)} selectedId={regionId} /> +
+
+ )} +
+
+
+
+
+
+
+ ) +} + +export default CreateUserDialog diff --git a/administration/src/components/users/ManageUsersController.tsx b/administration/src/components/users/ManageUsersController.tsx index 8f07b1b7b..8f5473bee 100644 --- a/administration/src/components/users/ManageUsersController.tsx +++ b/administration/src/components/users/ManageUsersController.tsx @@ -53,7 +53,7 @@ const ManageProjectUsers = () => { return ( - + ) } @@ -76,7 +76,7 @@ const ManageRegionUsers = ({ region }: { region: Region }) => { return ( - + ) } diff --git a/administration/src/components/users/RoleHelpButton.tsx b/administration/src/components/users/RoleHelpButton.tsx new file mode 100644 index 000000000..abba1ed9d --- /dev/null +++ b/administration/src/components/users/RoleHelpButton.tsx @@ -0,0 +1,42 @@ +import { Button, H4 } from '@blueprintjs/core' +import { Popover2 } from '@blueprintjs/popover2' +import { Role } from '../../generated/graphql' +import { roleToText } from './UsersTable' + +const RoleHelpButton = () => { + return ( + +

Welche Rollen haben welche Berechtigungen?

+
    +
  • + {roleToText(Role.ProjectAdmin)}: +
      +
    • Kann verwaltende Benutzer in allen Regionen verwalten.
    • +
    +
  • +
  • + {roleToText(Role.RegionAdmin)}: +
      +
    • Kann verwaltende Benutzer in seiner Region verwalten.
    • +
    • Kann digitale Karten in seiner Region erstellen.
    • +
    • Kann Anträge in seiner Region verwalten.
    • +
    +
  • +
  • + {roleToText(Role.RegionManager)}: +
      +
    • Kann digitale Karten in seiner Region erstellen.
    • +
    • Kann Anträge in seiner Region verwalten.
    • +
    +
  • +
+ + }> +