From ff2d5c907bf33c31f1d4b2500919965ca6097aaf Mon Sep 17 00:00:00 2001 From: HelaKaraa Date: Tue, 29 Oct 2024 16:53:44 +0100 Subject: [PATCH 01/11] feat#817: Add UserCopyRight filter --- izanami-frontend/src/pages/users.tsx | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/izanami-frontend/src/pages/users.tsx b/izanami-frontend/src/pages/users.tsx index b44ecb8e9..74e7c07ce 100644 --- a/izanami-frontend/src/pages/users.tsx +++ b/izanami-frontend/src/pages/users.tsx @@ -6,6 +6,7 @@ import queryClient from "../queryClient"; import { IzanamiContext, useAdmin } from "../securityContext"; import Select from "react-select"; import { customStyles } from "../styles/reactSelect"; +import AsyncSelect from "react-select/async"; import { createInvitation, @@ -87,6 +88,20 @@ export function Users() { (data: { email: string; admin: boolean; rights: TRights }) => createInvitation(data.email, data.admin, data.rights) ); + const loadOptions = ( + inputValue: string, + callback: (options: string[]) => void + ) => { + fetch(`/api/admin/users/search?query=${inputValue}&count=20`) + .then((resp) => resp.json()) + .then((data) => { + callback(data.map((d: string) => ({ label: d, value: d }))); + }) + .catch((error) => { + console.error("Error loading options", error); + callback([]); + }); + }; function OperationToggleForm(props: { bulkOperation: string; @@ -293,11 +308,35 @@ export function Users() { props: { autoFocus: true, }, + defaultValue: "", }, admin: { label: "Admin", type: "bool", }, + users: { + label: "Copy User Rights", + type: "object", + render: ({ onChange }) => ( + + !users?.includes(option.data.value) + } + isClearable + styles={customStyles} + cacheOptions + noOptionsMessage={({ inputValue }) => + inputValue && inputValue.length > 0 + ? "No user found for this search" + : "Start typing to search a user" + } + placeholder="Start typing to search a user" + onChange={(selected) => onChange?.(selected)} + /> + ), + }, rights: { label: () => "", type: "object", @@ -313,6 +352,7 @@ export function Users() { }, }} onSubmit={(ctx) => { + console.log("on Submit data", ctx); const backendRights = rightStateArrayToBackendMap(ctx.rights); const payload = { From 04bc18b0d1e7a27f459716fdcf27aaeadc3f44cb Mon Sep 17 00:00:00 2001 From: HelaKaraa Date: Wed, 30 Oct 2024 12:07:11 +0100 Subject: [PATCH 02/11] feat#817: Throw error if invited user email is already used. --- app/fr/maif/izanami/errors/Errors.scala | 2 +- izanami-frontend/src/pages/users.tsx | 11 +++++------ izanami-frontend/src/utils/queries.tsx | 22 +++++++++------------- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/app/fr/maif/izanami/errors/Errors.scala b/app/fr/maif/izanami/errors/Errors.scala index a66125092..e8133754b 100644 --- a/app/fr/maif/izanami/errors/Errors.scala +++ b/app/fr/maif/izanami/errors/Errors.scala @@ -73,7 +73,7 @@ case class UserAlreadyExist(user: String, email: String) status = BAD_REQUEST ) case class EmailAlreadyUsed(email: String) - extends IzanamiError(message = s"Email ${email} is already used by another user)", status = BAD_REQUEST) + extends IzanamiError(message = s"Email ${email} is already used by another user", status = BAD_REQUEST) case class InternalServerError(msg: String = "") extends IzanamiError(message = s"Something went wrong $msg", status = INTERNAL_SERVER_ERROR) case class MailSendingError(err: String, override val status: Int = INTERNAL_SERVER_ERROR) diff --git a/izanami-frontend/src/pages/users.tsx b/izanami-frontend/src/pages/users.tsx index 74e7c07ce..c1c9e195b 100644 --- a/izanami-frontend/src/pages/users.tsx +++ b/izanami-frontend/src/pages/users.tsx @@ -109,6 +109,7 @@ export function Users() { cancel: () => void; }) { const { bulkOperation, selectedRows, cancel } = props; + switch (bulkOperation) { case "Toggle Admin Role": { const adminOptions = [ @@ -211,7 +212,6 @@ export function Users() { ); } } - if (userQuery.isLoading) { return ; } else if (userQuery.isSuccess) { @@ -321,9 +321,6 @@ export function Users() { - !users?.includes(option.data.value) - } isClearable styles={customStyles} cacheOptions @@ -367,9 +364,11 @@ export function Users() { if (response && response.invitationUrl) { setCreationUrl(response.invitationUrl); } - return response; + setCreating(false); }) - .then(() => setCreating(false)); + .catch((error) => { + throw new Error(error); + }); }} onClose={() => setCreating(false)} submitText="Send invitation" diff --git a/izanami-frontend/src/utils/queries.tsx b/izanami-frontend/src/utils/queries.tsx index e31f05e01..b11b76bbf 100644 --- a/izanami-frontend/src/utils/queries.tsx +++ b/izanami-frontend/src/utils/queries.tsx @@ -1060,19 +1060,15 @@ export function createInvitation( admin: boolean, rights: TRights ): Promise<{ invitationUrl?: string } | null> { - return fetch(`/api/admin/invitation`, { - method: "POST", - body: JSON.stringify({ email, admin, rights }), - headers: { - "Content-Type": "application/json", - }, - }).then((response) => { - if (response.status === 201) { - return response.json(); - } else if (response.status === 204) { - return null; - } - }); + return handleFetchJsonResponse( + fetch(`/api/admin/invitation`, { + method: "POST", + body: JSON.stringify({ email, admin, rights }), + headers: { + "Content-Type": "application/json", + }, + }) + ); } export function fetchWebhooks(tenant: string): Promise { From 1a1383c1f189a2a3c8f9a2afbb94fae7c5234691 Mon Sep 17 00:00:00 2001 From: HelaKaraa Date: Wed, 30 Oct 2024 17:01:06 +0100 Subject: [PATCH 03/11] feat#817: modify sendInvitation method --- app/fr/maif/izanami/models/User.scala | 7 +- app/fr/maif/izanami/web/UserController.scala | 113 +++++++++++-------- izanami-frontend/src/pages/users.tsx | 40 +++---- izanami-frontend/src/utils/queries.tsx | 7 +- 4 files changed, 87 insertions(+), 80 deletions(-) diff --git a/app/fr/maif/izanami/models/User.scala b/app/fr/maif/izanami/models/User.scala index fd4d94293..c31aa23b6 100644 --- a/app/fr/maif/izanami/models/User.scala +++ b/app/fr/maif/izanami/models/User.scala @@ -48,7 +48,8 @@ case class UserInvitation( email: String, rights: Rights = Rights.EMPTY, admin: Boolean = false, - id: String = null + id: String = null, + userToCopy : Option[String] = None, ) case class UserRightsUpdateRequest( @@ -485,8 +486,8 @@ object User { implicit val userInvitationReads: Reads[UserInvitation] = ((__ \ "email").read[String] and (__ \ "rights").readWithDefault[Rights](Rights.EMPTY) and - (__ \ "admin").readWithDefault[Boolean](false))((email, rights, admin) => - UserInvitation(email = email, rights = rights, admin = admin) + (__ \ "admin").readWithDefault[Boolean](false) and (__ \ "userToCopy").readNullable[String])((email, rights, admin, userToCopy) => + UserInvitation(email = email, rights = rights, admin = admin, userToCopy= userToCopy) ) implicit val userRightsUpdateReads: Reads[UserRightsUpdateRequest] = ((__ \ "rights").read[Rights] and diff --git a/app/fr/maif/izanami/web/UserController.scala b/app/fr/maif/izanami/web/UserController.scala index ada6f53c0..085a4cdca 100644 --- a/app/fr/maif/izanami/web/UserController.scala +++ b/app/fr/maif/izanami/web/UserController.scala @@ -43,61 +43,74 @@ class UserController( } } - def sendInvitation(): Action[JsValue] = tenantRightsAction.async(parse.json) { implicit request => - { - def handleInvitation(email: String, id: String) = { - val token = env.jwtService.generateToken( - id, - Json.obj("invitation" -> id) - ) - - env.datastores.configuration - .readConfiguration() - .flatMap { - case Left(err) => err.toHttpResponse.future - case Right(configuration) if configuration.invitationMode == InvitationMode.Response => { - Created(Json.obj("invitationUrl" -> s"""${env.expositionUrl}/invitation?token=${token}""")).future - } - case Right(configuration) if configuration.invitationMode == InvitationMode.Mail => { - env.mails - .sendInvitationMail(email, token) - .map(futureResult => - futureResult.fold(err => InternalServerError(Json.obj("message" -> err.message)), _ => NoContent) - ) - } - case Right(c) => throw new RuntimeException("Unknown invitation mode " + c.invitationMode) + def sendInvitation(): Action[JsValue] = tenantRightsAction.async(parse.json) { implicit request => { + def handleInvitation(email: String, id: String) = { + val token = env.jwtService.generateToken( + id, + Json.obj("invitation" -> id) + ) + + env.datastores.configuration + .readConfiguration() + .flatMap { + case Left(err) => err.toHttpResponse.future + case Right(configuration) if configuration.invitationMode == InvitationMode.Response => { + Created(Json.obj("invitationUrl" -> s"""${env.expositionUrl}/invitation?token=${token}""")).future } - - } - - User.userInvitationReads - .reads(request.body) - .fold( - _ => Future.successful(Left(BadRequest("Invalid Payload"))), - invitation => - env.datastores.users - .findUserByMail(invitation.email) - .map(maybeUser => - maybeUser.map(_ => EmailAlreadyUsed(invitation.email).toHttpResponse).toLeft(invitation) + case Right(configuration) if configuration.invitationMode == InvitationMode.Mail => { + env.mails + .sendInvitationMail(email, token) + .map(futureResult => + futureResult.fold(err => InternalServerError(Json.obj("message" -> err.message)), _ => NoContent) ) - ) - .map { - case Right(invitation) if hasRight(request.user, invitation.admin, invitation.rights) => Right(invitation) - case Right(_) => Left(Forbidden(Json.obj("message" -> "Not enough rights"))) - case left => left + } + case Right(c) => throw new RuntimeException("Unknown invitation mode " + c.invitationMode) } - .flatMap(e => { - e.fold( - r => r.future, - invitation => + + } + + User.userInvitationReads + .reads(request.body) + .fold( + _ => Future.successful(Left(BadRequest("Invalid Payload"))), + invitation => + env.datastores.users + .findUserByMail(invitation.email) + .map(maybeUser => + maybeUser.map(_ => EmailAlreadyUsed(invitation.email).toHttpResponse).toLeft(invitation) + ) + ) + .map { + case Right(invitation) if hasRight(request.user, invitation.admin, invitation.rights) => Right(invitation) + case Right(_) => Left(Forbidden(Json.obj("message" -> "Not enough rights"))) + case left => left + } + .flatMap { + case Left(result) => Future.successful(result) + case Right(invitation) => + invitation.userToCopy match { + case Some(value) => env.datastores.users.findUserWithCompleteRights(value).flatMap { + case Some(user) => + val mergedTenants = user.rights.tenants ++ invitation.rights.tenants + val mergedRights = Rights(mergedTenants) + env.datastores.users + .createInvitation(invitation.email, invitation.admin, mergedRights, request.user.username) + .flatMap { + case Left(err) => Future.successful(err.toHttpResponse) + case Right(id) => handleInvitation(invitation.email, id) + } + case None => Future.successful(NotFound(Json.obj("message" -> "User to copy rights not found."))) + } + case _ => env.datastores.users .createInvitation(invitation.email, invitation.admin, invitation.rights, request.user.username) - .flatMap(either => - either.fold(err => err.toHttpResponse.future, id => handleInvitation(invitation.email, id)) - ) - ) - }) - } + .flatMap { + case Left(err) => Future.successful(err.toHttpResponse) + case Right(id) => handleInvitation(invitation.email, id) + } + } + } + } } def updateUser(user: String): Action[JsValue] = authAction.async(parse.json) { implicit request => diff --git a/izanami-frontend/src/pages/users.tsx b/izanami-frontend/src/pages/users.tsx index c1c9e195b..7dd30d46a 100644 --- a/izanami-frontend/src/pages/users.tsx +++ b/izanami-frontend/src/pages/users.tsx @@ -6,7 +6,6 @@ import queryClient from "../queryClient"; import { IzanamiContext, useAdmin } from "../securityContext"; import Select from "react-select"; import { customStyles } from "../styles/reactSelect"; -import AsyncSelect from "react-select/async"; import { createInvitation, @@ -85,23 +84,13 @@ export function Users() { ); const inviteUserMutation = useMutation( - (data: { email: string; admin: boolean; rights: TRights }) => - createInvitation(data.email, data.admin, data.rights) + (data: { + email: string; + admin: boolean; + rights: TRights; + userToCopy: string; + }) => createInvitation(data.email, data.admin, data.rights, data.userToCopy) ); - const loadOptions = ( - inputValue: string, - callback: (options: string[]) => void - ) => { - fetch(`/api/admin/users/search?query=${inputValue}&count=20`) - .then((resp) => resp.json()) - .then((data) => { - callback(data.map((d: string) => ({ label: d, value: d }))); - }) - .catch((error) => { - console.error("Error loading options", error); - callback([]); - }); - }; function OperationToggleForm(props: { bulkOperation: string; @@ -216,6 +205,7 @@ export function Users() { return ; } else if (userQuery.isSuccess) { const users = userQuery.data; + const columns: ColumnDef[] = [ { accessorKey: "username", @@ -315,15 +305,13 @@ export function Users() { type: "bool", }, users: { - label: "Copy User Rights", + label: "Copy user rights", type: "object", render: ({ onChange }) => ( - inputValue && inputValue.length > 0 ? "No user found for this search" @@ -331,6 +319,10 @@ export function Users() { } placeholder="Start typing to search a user" onChange={(selected) => onChange?.(selected)} + options={users.map(({ username }) => ({ + value: username, + label: username, + }))} /> ), }, @@ -349,15 +341,13 @@ export function Users() { }, }} onSubmit={(ctx) => { - console.log("on Submit data", ctx); const backendRights = rightStateArrayToBackendMap(ctx.rights); - const payload = { rights: backendRights, admin: ctx.admin, email: ctx.email, + userToCopy: ctx.users && ctx.users?.value, }; - return inviteUserMutation .mutateAsync(payload) .then((response) => { diff --git a/izanami-frontend/src/utils/queries.tsx b/izanami-frontend/src/utils/queries.tsx index b11b76bbf..2823bddaa 100644 --- a/izanami-frontend/src/utils/queries.tsx +++ b/izanami-frontend/src/utils/queries.tsx @@ -1058,12 +1058,15 @@ export function deleteUser(user: string): Promise { export function createInvitation( email: string, admin: boolean, - rights: TRights + rights: TRights, + userToCopy: string ): Promise<{ invitationUrl?: string } | null> { + console.log({ email, admin, rights, userToCopy }); + return handleFetchJsonResponse( fetch(`/api/admin/invitation`, { method: "POST", - body: JSON.stringify({ email, admin, rights }), + body: JSON.stringify({ email, admin, rights, userToCopy }), headers: { "Content-Type": "application/json", }, From aa2a21bdecdd649272a70136ead40c8461f02a98 Mon Sep 17 00:00:00 2001 From: HelaKaraa Date: Thu, 31 Oct 2024 15:18:37 +0100 Subject: [PATCH 04/11] feat#817: add test inviteUser --- test/fr/maif/izanami/api/BaseAPISpec.scala | 11 +++++++---- test/fr/maif/izanami/api/UsersAPISpec.scala | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/test/fr/maif/izanami/api/BaseAPISpec.scala b/test/fr/maif/izanami/api/BaseAPISpec.scala index ba1134022..c4073156b 100644 --- a/test/fr/maif/izanami/api/BaseAPISpec.scala +++ b/test/fr/maif/izanami/api/BaseAPISpec.scala @@ -912,7 +912,7 @@ object BaseAPISpec extends DefaultAwaitTimeout { cookies: Seq[WSCookie] = Seq() ): Future[RequestResult] = { val realEmail = Option(email).getOrElse(s"${user}@imaginarymail.frfrfezfezrf") - sendInvitationAsync(realEmail, admin, rights, cookies).flatMap(result => { + sendInvitationAsync(realEmail, admin, rights, null ,cookies).flatMap(result => { val url = (result.json \ "invitationUrl").as[String] val token = url.split("token=")(1) createUserWithTokenAsync(user, password, token).map(response => { @@ -985,9 +985,10 @@ object BaseAPISpec extends DefaultAwaitTimeout { email: String, admin: Boolean = false, rights: TestRights = null, + userToCopy: String = null, cookies: Seq[WSCookie] = Seq() ): RequestResult = { - val response = await(sendInvitationAsync(email, admin, rights, cookies)) + val response = await(sendInvitationAsync(email, admin, rights, userToCopy, cookies)) val jsonTry = Try { response.json @@ -999,6 +1000,7 @@ object BaseAPISpec extends DefaultAwaitTimeout { email: String, admin: Boolean = false, rights: TestRights = null, + userToCopy: String = null, cookies: Seq[WSCookie] = Seq() ): Future[WSResponse] = { val jsonRights = Option(rights).map(r => r.json).getOrElse(Json.obj()) @@ -1008,6 +1010,7 @@ object BaseAPISpec extends DefaultAwaitTimeout { Json.obj( "email" -> email, "admin" -> admin, + "userToCopy" -> userToCopy, "rights" -> jsonRights ) ) @@ -2353,8 +2356,8 @@ object BaseAPISpec extends DefaultAwaitTimeout { RequestResult(json = Try { response.json }, status = response.status) } - def sendInvitation(email: String, admin: Boolean = false, rights: TestRights = null): RequestResult = { - BaseAPISpec.this.sendInvitation(email = email, admin = admin, rights = rights, cookies = cookies) + def sendInvitation(email: String, admin: Boolean = false, rights: TestRights = null, userToCopy: String = null): RequestResult = { + BaseAPISpec.this.sendInvitation(email = email, admin = admin, rights = rights, userToCopy = userToCopy, cookies = cookies) } def fetchUserRights(): RequestResult = { diff --git a/test/fr/maif/izanami/api/UsersAPISpec.scala b/test/fr/maif/izanami/api/UsersAPISpec.scala index c67987820..539005d6b 100644 --- a/test/fr/maif/izanami/api/UsersAPISpec.scala +++ b/test/fr/maif/izanami/api/UsersAPISpec.scala @@ -319,6 +319,25 @@ class UsersAPISpec extends BaseAPISpec { } } + + "allow inviting user with another user rights if user is tenant admin" in { + val situation = TestSituationBuilder() + .withUsers(TestUser("testu").withTenantAdminRight("my-tenant")) + .withTenants(TestTenant("my-tenant").withProjectNames("my-project")) + .loggedAs("testu") + .build() + + situation.createUser("anotheruser", "barbar123", rights = TestRights().addTenantRight("my-tenant", "Read").addProjectRight("my-project", "my-tenant", "Admin")) + + val result = situation.sendInvitation( + "usercopyanotheruser@imaginaryemail.afezrfr", + admin = false, + null, + "anotheruser" + ) + result.status mustBe CREATED + } + "Complete user invitation / creation flow" should { "allow to create a new user with mail invitation via mailjet" in { val situation = TestSituationBuilder() From 932fca530a0256c3e6df92b48f47efd908d21784 Mon Sep 17 00:00:00 2001 From: HelaKaraa Date: Wed, 6 Nov 2024 17:28:18 +0100 Subject: [PATCH 05/11] Revert "feat#817: add test inviteUser" This reverts commit aa2a21bdecdd649272a70136ead40c8461f02a98. --- test/fr/maif/izanami/api/BaseAPISpec.scala | 11 ++++------- test/fr/maif/izanami/api/UsersAPISpec.scala | 19 ------------------- 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/test/fr/maif/izanami/api/BaseAPISpec.scala b/test/fr/maif/izanami/api/BaseAPISpec.scala index c4073156b..ba1134022 100644 --- a/test/fr/maif/izanami/api/BaseAPISpec.scala +++ b/test/fr/maif/izanami/api/BaseAPISpec.scala @@ -912,7 +912,7 @@ object BaseAPISpec extends DefaultAwaitTimeout { cookies: Seq[WSCookie] = Seq() ): Future[RequestResult] = { val realEmail = Option(email).getOrElse(s"${user}@imaginarymail.frfrfezfezrf") - sendInvitationAsync(realEmail, admin, rights, null ,cookies).flatMap(result => { + sendInvitationAsync(realEmail, admin, rights, cookies).flatMap(result => { val url = (result.json \ "invitationUrl").as[String] val token = url.split("token=")(1) createUserWithTokenAsync(user, password, token).map(response => { @@ -985,10 +985,9 @@ object BaseAPISpec extends DefaultAwaitTimeout { email: String, admin: Boolean = false, rights: TestRights = null, - userToCopy: String = null, cookies: Seq[WSCookie] = Seq() ): RequestResult = { - val response = await(sendInvitationAsync(email, admin, rights, userToCopy, cookies)) + val response = await(sendInvitationAsync(email, admin, rights, cookies)) val jsonTry = Try { response.json @@ -1000,7 +999,6 @@ object BaseAPISpec extends DefaultAwaitTimeout { email: String, admin: Boolean = false, rights: TestRights = null, - userToCopy: String = null, cookies: Seq[WSCookie] = Seq() ): Future[WSResponse] = { val jsonRights = Option(rights).map(r => r.json).getOrElse(Json.obj()) @@ -1010,7 +1008,6 @@ object BaseAPISpec extends DefaultAwaitTimeout { Json.obj( "email" -> email, "admin" -> admin, - "userToCopy" -> userToCopy, "rights" -> jsonRights ) ) @@ -2356,8 +2353,8 @@ object BaseAPISpec extends DefaultAwaitTimeout { RequestResult(json = Try { response.json }, status = response.status) } - def sendInvitation(email: String, admin: Boolean = false, rights: TestRights = null, userToCopy: String = null): RequestResult = { - BaseAPISpec.this.sendInvitation(email = email, admin = admin, rights = rights, userToCopy = userToCopy, cookies = cookies) + def sendInvitation(email: String, admin: Boolean = false, rights: TestRights = null): RequestResult = { + BaseAPISpec.this.sendInvitation(email = email, admin = admin, rights = rights, cookies = cookies) } def fetchUserRights(): RequestResult = { diff --git a/test/fr/maif/izanami/api/UsersAPISpec.scala b/test/fr/maif/izanami/api/UsersAPISpec.scala index 539005d6b..c67987820 100644 --- a/test/fr/maif/izanami/api/UsersAPISpec.scala +++ b/test/fr/maif/izanami/api/UsersAPISpec.scala @@ -319,25 +319,6 @@ class UsersAPISpec extends BaseAPISpec { } } - - "allow inviting user with another user rights if user is tenant admin" in { - val situation = TestSituationBuilder() - .withUsers(TestUser("testu").withTenantAdminRight("my-tenant")) - .withTenants(TestTenant("my-tenant").withProjectNames("my-project")) - .loggedAs("testu") - .build() - - situation.createUser("anotheruser", "barbar123", rights = TestRights().addTenantRight("my-tenant", "Read").addProjectRight("my-project", "my-tenant", "Admin")) - - val result = situation.sendInvitation( - "usercopyanotheruser@imaginaryemail.afezrfr", - admin = false, - null, - "anotheruser" - ) - result.status mustBe CREATED - } - "Complete user invitation / creation flow" should { "allow to create a new user with mail invitation via mailjet" in { val situation = TestSituationBuilder() From a2ea411696fff2be2752f4905c015d8598037fe9 Mon Sep 17 00:00:00 2001 From: HelaKaraa Date: Wed, 6 Nov 2024 17:31:25 +0100 Subject: [PATCH 06/11] Revert "feat#817: modify sendInvitation method" This reverts commit 1a1383c1f189a2a3c8f9a2afbb94fae7c5234691. --- app/fr/maif/izanami/models/User.scala | 7 +- app/fr/maif/izanami/web/UserController.scala | 113 ++++++++----------- izanami-frontend/src/pages/users.tsx | 40 ++++--- izanami-frontend/src/utils/queries.tsx | 7 +- 4 files changed, 80 insertions(+), 87 deletions(-) diff --git a/app/fr/maif/izanami/models/User.scala b/app/fr/maif/izanami/models/User.scala index c31aa23b6..fd4d94293 100644 --- a/app/fr/maif/izanami/models/User.scala +++ b/app/fr/maif/izanami/models/User.scala @@ -48,8 +48,7 @@ case class UserInvitation( email: String, rights: Rights = Rights.EMPTY, admin: Boolean = false, - id: String = null, - userToCopy : Option[String] = None, + id: String = null ) case class UserRightsUpdateRequest( @@ -486,8 +485,8 @@ object User { implicit val userInvitationReads: Reads[UserInvitation] = ((__ \ "email").read[String] and (__ \ "rights").readWithDefault[Rights](Rights.EMPTY) and - (__ \ "admin").readWithDefault[Boolean](false) and (__ \ "userToCopy").readNullable[String])((email, rights, admin, userToCopy) => - UserInvitation(email = email, rights = rights, admin = admin, userToCopy= userToCopy) + (__ \ "admin").readWithDefault[Boolean](false))((email, rights, admin) => + UserInvitation(email = email, rights = rights, admin = admin) ) implicit val userRightsUpdateReads: Reads[UserRightsUpdateRequest] = ((__ \ "rights").read[Rights] and diff --git a/app/fr/maif/izanami/web/UserController.scala b/app/fr/maif/izanami/web/UserController.scala index 085a4cdca..ada6f53c0 100644 --- a/app/fr/maif/izanami/web/UserController.scala +++ b/app/fr/maif/izanami/web/UserController.scala @@ -43,74 +43,61 @@ class UserController( } } - def sendInvitation(): Action[JsValue] = tenantRightsAction.async(parse.json) { implicit request => { - def handleInvitation(email: String, id: String) = { - val token = env.jwtService.generateToken( - id, - Json.obj("invitation" -> id) - ) - - env.datastores.configuration - .readConfiguration() - .flatMap { - case Left(err) => err.toHttpResponse.future - case Right(configuration) if configuration.invitationMode == InvitationMode.Response => { - Created(Json.obj("invitationUrl" -> s"""${env.expositionUrl}/invitation?token=${token}""")).future - } - case Right(configuration) if configuration.invitationMode == InvitationMode.Mail => { - env.mails - .sendInvitationMail(email, token) - .map(futureResult => - futureResult.fold(err => InternalServerError(Json.obj("message" -> err.message)), _ => NoContent) - ) + def sendInvitation(): Action[JsValue] = tenantRightsAction.async(parse.json) { implicit request => + { + def handleInvitation(email: String, id: String) = { + val token = env.jwtService.generateToken( + id, + Json.obj("invitation" -> id) + ) + + env.datastores.configuration + .readConfiguration() + .flatMap { + case Left(err) => err.toHttpResponse.future + case Right(configuration) if configuration.invitationMode == InvitationMode.Response => { + Created(Json.obj("invitationUrl" -> s"""${env.expositionUrl}/invitation?token=${token}""")).future + } + case Right(configuration) if configuration.invitationMode == InvitationMode.Mail => { + env.mails + .sendInvitationMail(email, token) + .map(futureResult => + futureResult.fold(err => InternalServerError(Json.obj("message" -> err.message)), _ => NoContent) + ) + } + case Right(c) => throw new RuntimeException("Unknown invitation mode " + c.invitationMode) } - case Right(c) => throw new RuntimeException("Unknown invitation mode " + c.invitationMode) - } - } - - User.userInvitationReads - .reads(request.body) - .fold( - _ => Future.successful(Left(BadRequest("Invalid Payload"))), - invitation => - env.datastores.users - .findUserByMail(invitation.email) - .map(maybeUser => - maybeUser.map(_ => EmailAlreadyUsed(invitation.email).toHttpResponse).toLeft(invitation) - ) - ) - .map { - case Right(invitation) if hasRight(request.user, invitation.admin, invitation.rights) => Right(invitation) - case Right(_) => Left(Forbidden(Json.obj("message" -> "Not enough rights"))) - case left => left } - .flatMap { - case Left(result) => Future.successful(result) - case Right(invitation) => - invitation.userToCopy match { - case Some(value) => env.datastores.users.findUserWithCompleteRights(value).flatMap { - case Some(user) => - val mergedTenants = user.rights.tenants ++ invitation.rights.tenants - val mergedRights = Rights(mergedTenants) - env.datastores.users - .createInvitation(invitation.email, invitation.admin, mergedRights, request.user.username) - .flatMap { - case Left(err) => Future.successful(err.toHttpResponse) - case Right(id) => handleInvitation(invitation.email, id) - } - case None => Future.successful(NotFound(Json.obj("message" -> "User to copy rights not found."))) - } - case _ => + + User.userInvitationReads + .reads(request.body) + .fold( + _ => Future.successful(Left(BadRequest("Invalid Payload"))), + invitation => + env.datastores.users + .findUserByMail(invitation.email) + .map(maybeUser => + maybeUser.map(_ => EmailAlreadyUsed(invitation.email).toHttpResponse).toLeft(invitation) + ) + ) + .map { + case Right(invitation) if hasRight(request.user, invitation.admin, invitation.rights) => Right(invitation) + case Right(_) => Left(Forbidden(Json.obj("message" -> "Not enough rights"))) + case left => left + } + .flatMap(e => { + e.fold( + r => r.future, + invitation => env.datastores.users .createInvitation(invitation.email, invitation.admin, invitation.rights, request.user.username) - .flatMap { - case Left(err) => Future.successful(err.toHttpResponse) - case Right(id) => handleInvitation(invitation.email, id) - } - } - } - } + .flatMap(either => + either.fold(err => err.toHttpResponse.future, id => handleInvitation(invitation.email, id)) + ) + ) + }) + } } def updateUser(user: String): Action[JsValue] = authAction.async(parse.json) { implicit request => diff --git a/izanami-frontend/src/pages/users.tsx b/izanami-frontend/src/pages/users.tsx index 7dd30d46a..c1c9e195b 100644 --- a/izanami-frontend/src/pages/users.tsx +++ b/izanami-frontend/src/pages/users.tsx @@ -6,6 +6,7 @@ import queryClient from "../queryClient"; import { IzanamiContext, useAdmin } from "../securityContext"; import Select from "react-select"; import { customStyles } from "../styles/reactSelect"; +import AsyncSelect from "react-select/async"; import { createInvitation, @@ -84,13 +85,23 @@ export function Users() { ); const inviteUserMutation = useMutation( - (data: { - email: string; - admin: boolean; - rights: TRights; - userToCopy: string; - }) => createInvitation(data.email, data.admin, data.rights, data.userToCopy) + (data: { email: string; admin: boolean; rights: TRights }) => + createInvitation(data.email, data.admin, data.rights) ); + const loadOptions = ( + inputValue: string, + callback: (options: string[]) => void + ) => { + fetch(`/api/admin/users/search?query=${inputValue}&count=20`) + .then((resp) => resp.json()) + .then((data) => { + callback(data.map((d: string) => ({ label: d, value: d }))); + }) + .catch((error) => { + console.error("Error loading options", error); + callback([]); + }); + }; function OperationToggleForm(props: { bulkOperation: string; @@ -205,7 +216,6 @@ export function Users() { return ; } else if (userQuery.isSuccess) { const users = userQuery.data; - const columns: ColumnDef[] = [ { accessorKey: "username", @@ -305,13 +315,15 @@ export function Users() { type: "bool", }, users: { - label: "Copy user rights", + label: "Copy User Rights", type: "object", render: ({ onChange }) => ( - + inputValue && inputValue.length > 0 + ? "No user found for this search" + : "Start typing to search a user" + } + placeholder="Start typing to search a user" + onChange={(selected) => { + setSelectedUser(selected); + onChange?.(selected); + }} + options={users.map(({ username }) => ({ + value: username, + label: username, + }))} + /> + {userRightsQuery.isLoading && ( + + )} + {userRightsQuery.isSuccess && selectedUser && ( +
+ onChange?.(v)} + /> +
+ )} + + ); + }, }, rights: { label: () => "", @@ -349,15 +381,18 @@ export function Users() { }, }} onSubmit={(ctx) => { - console.log("on Submit data", ctx); const backendRights = rightStateArrayToBackendMap(ctx.rights); - + const backendUserRights = rightStateArrayToBackendMap( + ctx.users + ); const payload = { - rights: backendRights, + rights: + Object.keys(backendRights.tenants || {}).length > 0 + ? backendRights + : backendUserRights, admin: ctx.admin, email: ctx.email, }; - return inviteUserMutation .mutateAsync(payload) .then((response) => { From b1e9f733df073727e950ef92251a54ce47dd59cb Mon Sep 17 00:00:00 2001 From: HelaKaraa Date: Tue, 12 Nov 2024 16:18:04 +0100 Subject: [PATCH 08/11] feat#817: add toggle button : Copy rights from another user --- izanami-frontend/src/pages/users.tsx | 142 ++++++++++--------------- izanami-frontend/src/utils/queries.tsx | 22 ++-- 2 files changed, 67 insertions(+), 97 deletions(-) diff --git a/izanami-frontend/src/pages/users.tsx b/izanami-frontend/src/pages/users.tsx index 82c1717ac..c209b57d3 100644 --- a/izanami-frontend/src/pages/users.tsx +++ b/izanami-frontend/src/pages/users.tsx @@ -58,6 +58,7 @@ export function Users() { }, } ); + const userUpdateMutation = useMutation( (user: { username: string; admin: boolean; rights: TRights }) => { const { username, ...rest } = user; @@ -82,25 +83,24 @@ export function Users() { }, } ); - - const inviteUserMutation = useMutation( - (data: { email: string; admin: boolean; rights: TRights }) => - createInvitation(data.email, data.admin, data.rights) - ); - const loadOptions = ( - inputValue: string, - callback: (options: string[]) => void - ) => { - fetch(`/api/admin/users/search?query=${inputValue}&count=20`) - .then((resp) => resp.json()) - .then((data) => { - callback(data.map((d: string) => ({ label: d, value: d }))); - }) - .catch((error) => { - console.error("Error loading options", error); - callback([]); - }); - }; + const inviteUserMutation = useMutation< + { invitationUrl?: string } | null, + Error, + { admin: boolean; email: string; rights: TRights; userToCopy: string } + >((data) => { + if (data.userToCopy) { + return queryUser(data.userToCopy) + .then((res) => { + data.rights = res.rights; + return createInvitation(data.email, data.admin, data.rights); + }) + .catch((error) => { + throw new Error(error); + }); + } else { + return createInvitation(data.email, data.admin, data.rights); + } + }); function OperationToggleForm(props: { bulkOperation: string; @@ -276,6 +276,7 @@ export function Users() { size: 15, }, ]; + return ( <>
@@ -313,85 +314,50 @@ export function Users() { label: "Admin", type: "bool", }, - users: { - label: "Copy user rights", - type: "object", - array: true, - render: ({ onChange }) => { - const [selectedUser, setSelectedUser] = - useState(null); - const userRightsQuery = useQuery( - userQueryKey(selectedUser?.value), - () => { - if (isAdmin) { - return queryUser(selectedUser?.value); - } - }, - { enabled: !!selectedUser } - ); - - return ( - <> - ({ + label: username, + value: username, + }))} + onChange={(v) => { + onChange?.(v); + }} + /> + ), }, }} - onSubmit={(ctx) => { + onSubmit={async (ctx) => { const backendRights = rightStateArrayToBackendMap(ctx.rights); - const backendUserRights = rightStateArrayToBackendMap( - ctx.users - ); const payload = { - rights: - Object.keys(backendRights.tenants || {}).length > 0 - ? backendRights - : backendUserRights, + rights: backendRights, admin: ctx.admin, email: ctx.email, + userToCopy: ctx.userToCopy?.value, }; return inviteUserMutation .mutateAsync(payload) diff --git a/izanami-frontend/src/utils/queries.tsx b/izanami-frontend/src/utils/queries.tsx index b11b76bbf..e31f05e01 100644 --- a/izanami-frontend/src/utils/queries.tsx +++ b/izanami-frontend/src/utils/queries.tsx @@ -1060,15 +1060,19 @@ export function createInvitation( admin: boolean, rights: TRights ): Promise<{ invitationUrl?: string } | null> { - return handleFetchJsonResponse( - fetch(`/api/admin/invitation`, { - method: "POST", - body: JSON.stringify({ email, admin, rights }), - headers: { - "Content-Type": "application/json", - }, - }) - ); + return fetch(`/api/admin/invitation`, { + method: "POST", + body: JSON.stringify({ email, admin, rights }), + headers: { + "Content-Type": "application/json", + }, + }).then((response) => { + if (response.status === 201) { + return response.json(); + } else if (response.status === 204) { + return null; + } + }); } export function fetchWebhooks(tenant: string): Promise { From 89fed1bb180f904a989374b3bc14cf533a566796 Mon Sep 17 00:00:00 2001 From: HelaKaraa Date: Wed, 13 Nov 2024 11:58:17 +0100 Subject: [PATCH 09/11] feat#817: codeReview modification code --- izanami-frontend/src/pages/users.tsx | 52 ++++++++++++++++------------ 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/izanami-frontend/src/pages/users.tsx b/izanami-frontend/src/pages/users.tsx index c209b57d3..e89ceea96 100644 --- a/izanami-frontend/src/pages/users.tsx +++ b/izanami-frontend/src/pages/users.tsx @@ -83,24 +83,12 @@ export function Users() { }, } ); - const inviteUserMutation = useMutation< - { invitationUrl?: string } | null, - Error, - { admin: boolean; email: string; rights: TRights; userToCopy: string } - >((data) => { - if (data.userToCopy) { - return queryUser(data.userToCopy) - .then((res) => { - data.rights = res.rights; - return createInvitation(data.email, data.admin, data.rights); - }) - .catch((error) => { - throw new Error(error); - }); - } else { - return createInvitation(data.email, data.admin, data.rights); - } - }); + const inviteUserMutation = useMutation( + (data: { email: string; admin: boolean; rights: TRights }) => + createInvitation(data.email, data.admin, data.rights) + ); + + const readUser = useMutation((user: string) => queryUser(user)); function OperationToggleForm(props: { bulkOperation: string; @@ -319,7 +307,7 @@ export function Users() { type: "bool", }, rights: { - label: "", + label: () => "", type: "object", array: true, visible: ({ rawValues }) => !rawValues.useCopyUserRights, @@ -351,14 +339,34 @@ export function Users() { ), }, }} - onSubmit={async (ctx) => { + onSubmit={(ctx) => { const backendRights = rightStateArrayToBackendMap(ctx.rights); - const payload = { + let payload = { rights: backendRights, admin: ctx.admin, email: ctx.email, - userToCopy: ctx.userToCopy?.value, }; + if (ctx.userToCopy) { + readUser + .mutateAsync(ctx.userToCopy.value) + .then((res: TUser) => { + payload = { ...payload, rights: res.rights }; + return inviteUserMutation + .mutateAsync(payload) + .then((response) => { + if (response && response.invitationUrl) { + setCreationUrl(response.invitationUrl); + } + setCreating(false); + }) + .catch((error) => { + throw new Error(error); + }); + }) + .catch((error: any) => { + throw new Error(error); + }); + } return inviteUserMutation .mutateAsync(payload) .then((response) => { From 2ac4aac543242105d0a0f89f4d075184fbbd6146 Mon Sep 17 00:00:00 2001 From: HelaKaraa Date: Fri, 15 Nov 2024 09:42:02 +0100 Subject: [PATCH 10/11] feat#817: OnSubmit without Async --- izanami-frontend/src/pages/users.tsx | 47 +++++++++++++--------------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/izanami-frontend/src/pages/users.tsx b/izanami-frontend/src/pages/users.tsx index e89ceea96..aca3d28ff 100644 --- a/izanami-frontend/src/pages/users.tsx +++ b/izanami-frontend/src/pages/users.tsx @@ -84,12 +84,26 @@ export function Users() { } ); const inviteUserMutation = useMutation( - (data: { email: string; admin: boolean; rights: TRights }) => - createInvitation(data.email, data.admin, data.rights) + (data: { + admin: boolean; + email: string; + rights: TRights; + userToCopy: string; + }) => { + if (data.userToCopy) { + return queryUser(data.userToCopy).then((res: TUser) => { + const updatedData = { ...data, rights: res.rights }; + return createInvitation( + updatedData.email, + updatedData.admin, + updatedData.rights + ); + }); + } + return createInvitation(data.email, data.admin, data.rights); + } ); - const readUser = useMutation((user: string) => queryUser(user)); - function OperationToggleForm(props: { bulkOperation: string; selectedRows: UserType[]; @@ -341,32 +355,13 @@ export function Users() { }} onSubmit={(ctx) => { const backendRights = rightStateArrayToBackendMap(ctx.rights); - let payload = { + const payload = { rights: backendRights, admin: ctx.admin, email: ctx.email, + userToCopy: ctx.userToCopy?.value, }; - if (ctx.userToCopy) { - readUser - .mutateAsync(ctx.userToCopy.value) - .then((res: TUser) => { - payload = { ...payload, rights: res.rights }; - return inviteUserMutation - .mutateAsync(payload) - .then((response) => { - if (response && response.invitationUrl) { - setCreationUrl(response.invitationUrl); - } - setCreating(false); - }) - .catch((error) => { - throw new Error(error); - }); - }) - .catch((error: any) => { - throw new Error(error); - }); - } + return inviteUserMutation .mutateAsync(payload) .then((response) => { From 382c2fe1e08ba2cb3b5339a16d906196cf6ede2b Mon Sep 17 00:00:00 2001 From: HelaKaraa Date: Mon, 18 Nov 2024 11:29:22 +0100 Subject: [PATCH 11/11] feat#817: codeReview admin Right --- izanami-frontend/src/pages/users.tsx | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/izanami-frontend/src/pages/users.tsx b/izanami-frontend/src/pages/users.tsx index aca3d28ff..a6f225f3e 100644 --- a/izanami-frontend/src/pages/users.tsx +++ b/izanami-frontend/src/pages/users.tsx @@ -92,12 +92,7 @@ export function Users() { }) => { if (data.userToCopy) { return queryUser(data.userToCopy).then((res: TUser) => { - const updatedData = { ...data, rights: res.rights }; - return createInvitation( - updatedData.email, - updatedData.admin, - updatedData.rights - ); + return createInvitation(data.email, res.admin, res.rights); }); } return createInvitation(data.email, data.admin, data.rights); @@ -312,14 +307,15 @@ export function Users() { }, defaultValue: "", }, - admin: { - label: "Admin", - type: "bool", - }, useCopyUserRights: { label: "Copy rights from another user", type: "bool", }, + admin: { + label: "Admin", + type: "bool", + visible: ({ rawValues }) => !rawValues.useCopyUserRights, + }, rights: { label: () => "", type: "object", @@ -369,9 +365,6 @@ export function Users() { setCreationUrl(response.invitationUrl); } setCreating(false); - }) - .catch((error) => { - throw new Error(error); }); }} onClose={() => setCreating(false)}