From 3650a7d20c6a8f8505bd46c575b09bdb74b572e9 Mon Sep 17 00:00:00 2001 From: eikek Date: Tue, 7 Sep 2021 22:35:16 +0200 Subject: [PATCH 1/5] Make sure DML statements use the correct logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It also seems to be necessary now to add the space in the `WHERE`. Normally, the `fr` interpolator would add spaces…. --- modules/store/src/main/scala/docspell/store/qb/DML.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/store/src/main/scala/docspell/store/qb/DML.scala b/modules/store/src/main/scala/docspell/store/qb/DML.scala index 0ba5f072..f448b634 100644 --- a/modules/store/src/main/scala/docspell/store/qb/DML.scala +++ b/modules/store/src/main/scala/docspell/store/qb/DML.scala @@ -8,19 +8,20 @@ package docspell.store.qb import cats.data.{NonEmptyList => Nel} +import docspell.store.impl.DoobieMeta import docspell.store.qb.impl._ import doobie._ import doobie.implicits._ -object DML { +object DML extends DoobieMeta { private val comma = fr"," def delete(table: TableDef, cond: Condition): ConnectionIO[Int] = deleteFragment(table, cond).update.run def deleteFragment(table: TableDef, cond: Condition): Fragment = - fr"DELETE FROM" ++ FromExprBuilder.buildTable(table) ++ fr"WHERE" ++ ConditionBuilder + fr"DELETE FROM" ++ FromExprBuilder.buildTable(table) ++ fr" WHERE" ++ ConditionBuilder .build(cond) def insert(table: TableDef, cols: Nel[Column[_]], values: Fragment): ConnectionIO[Int] = From 8df235e9db83be808c2e4796d5d5c735f208176c Mon Sep 17 00:00:00 2001 From: eikek Date: Mon, 6 Sep 2021 21:54:30 +0200 Subject: [PATCH 2/5] Delete the user along its data --- .../docspell/backend/ops/OCollective.scala | 20 ++- .../restserver/routes/UserRoutes.scala | 8 ++ .../scala/docspell/store/queries/QUser.scala | 131 ++++++++++++++++++ .../store/records/RFolderMember.scala | 3 + .../docspell/store/records/RRememberMe.scala | 2 +- .../scala/docspell/store/records/RUser.scala | 3 + 6 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 modules/store/src/main/scala/docspell/store/queries/QUser.scala diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala index 3e93217b..b2479e3e 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -15,7 +15,7 @@ import docspell.backend.PasswordCrypt import docspell.backend.ops.OCollective._ import docspell.common._ import docspell.store.UpdateResult -import docspell.store.queries.QCollective +import docspell.store.queries.{QCollective, QUser} import docspell.store.queue.JobQueue import docspell.store.records._ import docspell.store.usertask.{UserTask, UserTaskScope, UserTaskStore} @@ -37,7 +37,11 @@ trait OCollective[F[_]] { def update(s: RUser): F[AddResult] - def deleteUser(login: Ident, collective: Ident): F[AddResult] + /** Deletes the user and all its data. */ + def deleteUser(login: Ident, collective: Ident): F[UpdateResult] + + /** Return an excerpt of what would be deleted, when the user is deleted. */ + def getDeleteUserData(accountId: AccountId): F[DeleteUserData] def insights(collective: Ident): F[InsightData] @@ -91,6 +95,9 @@ object OCollective { type EmptyTrash = REmptyTrashSetting.EmptyTrash val EmptyTrash = REmptyTrashSetting.EmptyTrash + type DeleteUserData = QUser.UserData + val DeleteUserData = QUser.UserData + sealed trait PassResetResult object PassResetResult { case class Success(newPw: Password) extends PassResetResult @@ -215,8 +222,13 @@ object OCollective { def update(s: RUser): F[AddResult] = store.add(RUser.update(s), RUser.exists(s.login)) - def deleteUser(login: Ident, collective: Ident): F[AddResult] = - store.transact(RUser.delete(login, collective)).attempt.map(AddResult.fromUpdate) + def getDeleteUserData(accountId: AccountId): F[DeleteUserData] = + store.transact(QUser.getUserData(accountId)) + + def deleteUser(login: Ident, collective: Ident): F[UpdateResult] = + UpdateResult.fromUpdate( + store.transact(QUser.deleteUserAndData(AccountId(collective, login))) + ) def insights(collective: Ident): F[InsightData] = store.transact(QCollective.getInsights(collective)) diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala index 756829b1..cfb0cf36 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala @@ -66,6 +66,14 @@ object UserRoutes { ar <- backend.collective.deleteUser(id, user.account.collective) resp <- Ok(basicResult(ar, "User deleted.")) } yield resp + + case GET -> Root / Ident(username) / "deleteData" => + for { + data <- backend.collective.getDeleteUserData( + AccountId(user.account.collective, username) + ) + resp <- Ok(DeleteUserData(data.ownedFolders.map(_.id), data.sentMails)) + } yield resp } } diff --git a/modules/store/src/main/scala/docspell/store/queries/QUser.scala b/modules/store/src/main/scala/docspell/store/queries/QUser.scala new file mode 100644 index 00000000..a666fe8f --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/QUser.scala @@ -0,0 +1,131 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.store.queries + +import cats.implicits._ + +import docspell.common._ +import docspell.store.qb.DML +import docspell.store.qb.DSL._ +import docspell.store.records._ + +import doobie._ + +object QUser { + private val logger = Logger.log4s[ConnectionIO](org.log4s.getLogger) + + final case class UserData( + ownedFolders: List[Ident], + sentMails: Int + ) + + def getUserData(accountId: AccountId): ConnectionIO[UserData] = { + val folder = RFolder.as("f") + val mail = RSentMail.as("m") + val mitem = RSentMailItem.as("mi") + val user = RUser.as("u") + + for { + uid <- loadUserId(accountId).map(_.getOrElse(Ident.unsafe(""))) + folders <- run( + select(folder.name), + from(folder), + folder.owner === uid && folder.collective === accountId.collective + ).query[Ident].to[List] + mails <- run( + select(count(mail.id)), + from(mail) + .innerJoin(mitem, mail.id === mitem.sentMailId) + .innerJoin(user, user.uid === mail.uid), + user.login === accountId.user && user.cid === accountId.collective + ).query[Int].unique + } yield UserData(folders, mails) + } + + def deleteUserAndData(accountId: AccountId): ConnectionIO[Int] = + for { + uid <- loadUserId(accountId).map(_.getOrElse(Ident.unsafe(""))) + _ <- logger.info(s"Remove user ${accountId.asString} (uid=${uid.id})") + + n1 <- deleteUserFolders(uid) + + n2 <- deleteUserSentMails(uid) + _ <- logger.info(s"Removed $n2 sent mails") + + n3 <- deleteRememberMe(accountId) + _ <- logger.info(s"Removed $n3 remember me tokens") + + n4 <- deleteTotp(uid) + _ <- logger.info(s"Removed $n4 totp secrets") + + n5 <- deleteMailSettings(uid) + _ <- logger.info(s"Removed $n5 mail settings") + + nu <- RUser.deleteById(uid) + } yield nu + n1 + n2 + n3 + n4 + n5 + + def deleteUserFolders(uid: Ident): ConnectionIO[Int] = { + val folder = RFolder.as("f") + val member = RFolderMember.as("fm") + for { + folders <- run( + select(folder.id), + from(folder), + folder.owner === uid + ).query[Ident].to[List] + _ <- logger.info(s"Removing folders: ${folders.map(_.id)}") + + ri <- folders.traverse(RItem.removeFolder) + _ <- logger.info(s"Removed folders from items: $ri") + rs <- folders.traverse(RSource.removeFolder) + _ <- logger.info(s"Removed folders from sources: $rs") + rf <- folders.traverse(RFolderMember.deleteAll) + _ <- logger.info(s"Removed folders from members: $rf") + + n1 <- DML.delete(member, member.user === uid) + _ <- logger.info(s"Removed $n1 members for owning folders.") + n2 <- DML.delete(folder, folder.owner === uid) + _ <- logger.info(s"Removed $n2 folders.") + + } yield n1 + n2 + ri.sum + rs.sum + rf.sum + } + + def deleteUserSentMails(uid: Ident): ConnectionIO[Int] = { + val mail = RSentMail.as("m") + for { + ids <- run(select(mail.id), from(mail), mail.uid === uid).query[Ident].to[List] + n1 <- ids.traverse(RSentMailItem.deleteMail) + n2 <- ids.traverse(RSentMail.delete) + } yield n1.sum + n2.sum + } + + def deleteRememberMe(id: AccountId): ConnectionIO[Int] = + DML.delete( + RRememberMe.T, + RRememberMe.T.cid === id.collective && RRememberMe.T.username === id.user + ) + + def deleteTotp(uid: Ident): ConnectionIO[Int] = + DML.delete(RTotp.T, RTotp.T.userId === uid) + + def deleteMailSettings(uid: Ident): ConnectionIO[Int] = { + val smtp = RUserEmail.as("ms") + val imap = RUserImap.as("mi") + for { + n1 <- DML.delete(smtp, smtp.uid === uid) + n2 <- DML.delete(imap, imap.uid === uid) + } yield n1 + n2 + } + + private def loadUserId(id: AccountId): ConnectionIO[Option[Ident]] = + run( + select(RUser.T.uid), + from(RUser.T), + RUser.T.cid === id.collective && RUser.T.login === id.user + ).query[Ident].option + +} diff --git a/modules/store/src/main/scala/docspell/store/records/RFolderMember.scala b/modules/store/src/main/scala/docspell/store/records/RFolderMember.scala index 9452b4f4..71577715 100644 --- a/modules/store/src/main/scala/docspell/store/records/RFolderMember.scala +++ b/modules/store/src/main/scala/docspell/store/records/RFolderMember.scala @@ -64,4 +64,7 @@ object RFolderMember { def deleteAll(folderId: Ident): ConnectionIO[Int] = DML.delete(T, T.folder === folderId) + + def deleteMemberships(userId: Ident): ConnectionIO[Int] = + DML.delete(T, T.user === userId) } diff --git a/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala b/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala index 8938d06c..e9480655 100644 --- a/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala +++ b/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala @@ -31,7 +31,7 @@ object RRememberMe { val all = NonEmptyList.of[Column[_]](id, cid, username, created, uses) } - private val T = Table(None) + val T = Table(None) def as(alias: String): Table = Table(Some(alias)) diff --git a/modules/store/src/main/scala/docspell/store/records/RUser.scala b/modules/store/src/main/scala/docspell/store/records/RUser.scala index 57b23ad8..a5e34779 100644 --- a/modules/store/src/main/scala/docspell/store/records/RUser.scala +++ b/modules/store/src/main/scala/docspell/store/records/RUser.scala @@ -168,4 +168,7 @@ object RUser { val t = Table(None) DML.delete(t, t.cid === coll && t.login === user) } + + def deleteById(uid: Ident): ConnectionIO[Int] = + DML.delete(T, T.uid === uid) } From 736968b0490f3f9982f1885337acf3a875f33682 Mon Sep 17 00:00:00 2001 From: eikek Date: Tue, 7 Sep 2021 21:59:38 +0200 Subject: [PATCH 3/5] Fix adding a new user and don't allow to add non-local users The user structure must provide the correct account source now. --- .../main/scala/docspell/backend/ops/OCollective.scala | 11 +++++++---- .../src/main/scala/docspell/store/AddResult.scala | 2 ++ modules/webapp/src/main/elm/Comp/UserForm.elm | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala index b2479e3e..c49a31ae 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -214,10 +214,13 @@ object OCollective { store.transact(RUser.findAll(collective, _.login)) def add(s: RUser): F[AddResult] = - store.add( - RUser.insert(s.copy(password = PasswordCrypt.crypt(s.password))), - RUser.exists(s.login) - ) + if (s.source != AccountSource.Local) + AddResult.failure(new Exception("Only local accounts can be created!")).pure[F] + else + store.add( + RUser.insert(s.copy(password = PasswordCrypt.crypt(s.password))), + RUser.exists(s.login) + ) def update(s: RUser): F[AddResult] = store.add(RUser.update(s), RUser.exists(s.login)) diff --git a/modules/store/src/main/scala/docspell/store/AddResult.scala b/modules/store/src/main/scala/docspell/store/AddResult.scala index 86bcf218..b14b623a 100644 --- a/modules/store/src/main/scala/docspell/store/AddResult.scala +++ b/modules/store/src/main/scala/docspell/store/AddResult.scala @@ -50,4 +50,6 @@ object AddResult { def fold[A](fa: Success.type => A, fb: EntityExists => A, fc: Failure => A): A = fc(this) } + def failure(ex: Exception): AddResult = + Failure(ex) } diff --git a/modules/webapp/src/main/elm/Comp/UserForm.elm b/modules/webapp/src/main/elm/Comp/UserForm.elm index 046e5996..86eba889 100644 --- a/modules/webapp/src/main/elm/Comp/UserForm.elm +++ b/modules/webapp/src/main/elm/Comp/UserForm.elm @@ -85,6 +85,7 @@ getUser model = , email = model.email , state = state , password = model.password + , source = "local" } From e89b571ab2f344f6683a506398c3491a864a8d53 Mon Sep 17 00:00:00 2001 From: eikek Date: Tue, 7 Sep 2021 00:18:02 +0200 Subject: [PATCH 4/5] Add a route to show what is deleted --- .../src/main/resources/docspell-openapi.yml | 51 +++++++++++++++++-- modules/webapp/src/main/elm/Api.elm | 11 ++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index b6d5112c..b6a5648c 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1309,9 +1309,9 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicResult" - /sec/user/{id}: + /sec/user/{username}: delete: - operationId: "sec-user-delete-by-id" + operationId: "sec-user-delete-by-username" tags: [ Collective ] summary: Delete a user. description: | @@ -1319,7 +1319,7 @@ paths: security: - authTokenHeader: [] parameters: - - $ref: "#/components/parameters/id" + - $ref: "#/components/parameters/username" responses: 200: description: Ok @@ -1327,6 +1327,27 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicResult" + /sec/user/{username}/deleteData: + get: + operationId: "sec-user-delete-data" + tags: [ Collective ] + summary: Shows some data that would be deleted if the user is deleted + description: | + Gets some data that would be deleted, when the user with the + given username is deleted. The `username` must be part of this + collective. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/username" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteUserData" + /sec/user/changePassword: post: operationId: "sec-user-change-password" @@ -4068,6 +4089,23 @@ paths: components: schemas: + DeleteUserData: + description: | + An excerpt of data that would be deleted when deleting the + associated user. + required: + - folders + - sentMails + properties: + folders: + type: array + items: + type: string + format: ident + sentMails: + type: integer + format: int32 + SecondFactor: description: | Provide a second factor for login. @@ -6206,6 +6244,13 @@ components: required: true schema: type: string + username: + name: username + in: path + required: true + description: The username of a user of this collective + schema: + type: string itemId: name: itemId in: path diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index e9588df1..325dddf1 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -43,6 +43,7 @@ module Api exposing , deleteSource , deleteTag , deleteUser + , deleteUserData , disableOtp , fileURL , getAttachmentMeta @@ -162,6 +163,7 @@ import Api.Model.CollectiveSettings exposing (CollectiveSettings) import Api.Model.ContactList exposing (ContactList) import Api.Model.CustomFieldList exposing (CustomFieldList) import Api.Model.CustomFieldValue exposing (CustomFieldValue) +import Api.Model.DeleteUserData exposing (DeleteUserData) import Api.Model.DirectionValue exposing (DirectionValue) import Api.Model.EmailSettings exposing (EmailSettings) import Api.Model.EmailSettingsList exposing (EmailSettingsList) @@ -1467,6 +1469,15 @@ deleteUser flags user receive = } +deleteUserData : Flags -> String -> (Result Http.Error DeleteUserData -> msg) -> Cmd msg +deleteUserData flags username receive = + Http2.authGet + { url = flags.config.baseUrl ++ "/api/v1/sec/user/" ++ username ++ "/deleteData" + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.DeleteUserData.decoder + } + + --- Job Queue From a94aeff75cb1ee2215751690062b81589862fbaf Mon Sep 17 00:00:00 2001 From: eikek Date: Wed, 8 Sep 2021 00:22:00 +0200 Subject: [PATCH 5/5] Add ui for showing data to be deleted for a user --- modules/webapp/src/main/elm/Api.elm | 6 +- modules/webapp/src/main/elm/Comp/Basic.elm | 48 +++++- .../webapp/src/main/elm/Comp/UserManage.elm | 159 ++++++++++++++---- .../src/main/elm/Messages/Comp/UserManage.elm | 3 + modules/webapp/src/main/elm/Styles.elm | 12 +- 5 files changed, 179 insertions(+), 49 deletions(-) diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 325dddf1..e00110e9 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -43,7 +43,6 @@ module Api exposing , deleteSource , deleteTag , deleteUser - , deleteUserData , disableOtp , fileURL , getAttachmentMeta @@ -52,6 +51,7 @@ module Api exposing , getCollectiveSettings , getContacts , getCustomFields + , getDeleteUserData , getEquipment , getEquipments , getFolderDetail @@ -1469,8 +1469,8 @@ deleteUser flags user receive = } -deleteUserData : Flags -> String -> (Result Http.Error DeleteUserData -> msg) -> Cmd msg -deleteUserData flags username receive = +getDeleteUserData : Flags -> String -> (Result Http.Error DeleteUserData -> msg) -> Cmd msg +getDeleteUserData flags username receive = Http2.authGet { url = flags.config.baseUrl ++ "/api/v1/sec/user/" ++ username ++ "/deleteData" , account = getAccount flags diff --git a/modules/webapp/src/main/elm/Comp/Basic.elm b/modules/webapp/src/main/elm/Comp/Basic.elm index 75f88803..2f20f4af 100644 --- a/modules/webapp/src/main/elm/Comp/Basic.elm +++ b/modules/webapp/src/main/elm/Comp/Basic.elm @@ -6,7 +6,9 @@ module Comp.Basic exposing - ( editLinkLabel + ( contentDimmer + , deleteButton + , editLinkLabel , editLinkTableCell , genericButton , horizontalDivider @@ -89,6 +91,27 @@ secondaryButton model = } +deleteButton : + { x + | label : String + , icon : String + , disabled : Bool + , handler : Attribute msg + , attrs : List (Attribute msg) + } + -> Html msg +deleteButton model = + genericButton + { label = model.label + , icon = model.icon + , handler = model.handler + , disabled = model.disabled + , attrs = model.attrs + , baseStyle = S.deleteButtonMain + , activeStyle = S.deleteButtonHover + } + + secondaryBasicButton : { x | label : String @@ -182,18 +205,27 @@ linkLabel model = loadingDimmer : { label : String, active : Bool } -> Html msg loadingDimmer cfg = + let + content = + div [ class "text-gray-200" ] + [ i [ class "fa fa-circle-notch animate-spin" ] [] + , span [ class "ml-2" ] + [ text cfg.label + ] + ] + in + contentDimmer cfg.active content + + +contentDimmer : Bool -> Html msg -> Html msg +contentDimmer active content = div [ classList - [ ( "hidden", not cfg.active ) + [ ( "hidden", not active ) ] , class S.dimmer ] - [ div [ class "text-gray-200" ] - [ i [ class "fa fa-circle-notch animate-spin" ] [] - , span [ class "ml-2" ] - [ text cfg.label - ] - ] + [ content ] diff --git a/modules/webapp/src/main/elm/Comp/UserManage.elm b/modules/webapp/src/main/elm/Comp/UserManage.elm index e894612a..0c601476 100644 --- a/modules/webapp/src/main/elm/Comp/UserManage.elm +++ b/modules/webapp/src/main/elm/Comp/UserManage.elm @@ -15,18 +15,18 @@ module Comp.UserManage exposing import Api import Api.Model.BasicResult exposing (BasicResult) +import Api.Model.DeleteUserData exposing (DeleteUserData) import Api.Model.User import Api.Model.UserList exposing (UserList) import Comp.Basic as B import Comp.MenuBar as MB import Comp.UserForm import Comp.UserTable -import Comp.YesNoDimmer import Data.Flags exposing (Flags) import Data.UiSettings exposing (UiSettings) import Html exposing (..) import Html.Attributes exposing (..) -import Html.Events exposing (onSubmit) +import Html.Events exposing (onClick, onSubmit) import Http import Messages.Comp.UserManage exposing (Texts) import Styles as S @@ -39,10 +39,16 @@ type alias Model = , viewMode : ViewMode , formError : FormError , loading : Bool - , deleteConfirm : Comp.YesNoDimmer.Model + , deleteConfirm : DimmerMode } +type DimmerMode + = DimmerOff + | DimmerLoading + | DimmerUserData DeleteUserData + + type ViewMode = Table | Form @@ -53,6 +59,7 @@ type FormError | FormErrorSubmit String | FormErrorHttp Http.Error | FormErrorInvalid + | FormErrorCurrentUser emptyModel : Model @@ -62,7 +69,7 @@ emptyModel = , viewMode = Table , formError = FormErrorNone , loading = False - , deleteConfirm = Comp.YesNoDimmer.emptyModel + , deleteConfirm = DimmerOff } @@ -75,8 +82,10 @@ type Msg | InitNewUser | Submit | SubmitResp (Result Http.Error BasicResult) - | YesNoMsg Comp.YesNoDimmer.Msg | RequestDelete + | GetDeleteDataResp (Result Http.Error DeleteUserData) + | DeleteUserNow String + | CancelDelete update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) @@ -183,12 +192,44 @@ update flags msg model = ( m3, c3 ) = update flags LoadUsers m2 in - ( { m3 | loading = False }, Cmd.batch [ c2, c3 ] ) + ( { m3 | loading = False, deleteConfirm = DimmerOff }, Cmd.batch [ c2, c3 ] ) else - ( { model | formError = FormErrorSubmit res.message, loading = False }, Cmd.none ) + ( { model + | formError = FormErrorSubmit res.message + , loading = False + , deleteConfirm = DimmerOff + } + , Cmd.none + ) SubmitResp (Err err) -> + ( { model + | formError = FormErrorHttp err + , loading = False + , deleteConfirm = DimmerOff + } + , Cmd.none + ) + + RequestDelete -> + let + login = + Maybe.map .user flags.account + |> Maybe.withDefault "" + in + if model.formModel.user.login == login then + ( { model | formError = FormErrorCurrentUser }, Cmd.none ) + + else + ( { model | deleteConfirm = DimmerLoading } + , Api.getDeleteUserData flags model.formModel.user.login GetDeleteDataResp + ) + + GetDeleteDataResp (Ok data) -> + ( { model | deleteConfirm = DimmerUserData data }, Cmd.none ) + + GetDeleteDataResp (Err err) -> ( { model | formError = FormErrorHttp err , loading = False @@ -196,29 +237,15 @@ update flags msg model = , Cmd.none ) - RequestDelete -> - update flags (YesNoMsg Comp.YesNoDimmer.activate) model + CancelDelete -> + ( { model | deleteConfirm = DimmerOff }, Cmd.none ) - YesNoMsg m -> - let - ( cm, confirmed ) = - Comp.YesNoDimmer.update m model.deleteConfirm - - user = - Comp.UserForm.getUser model.formModel - - cmd = - if confirmed then - Api.deleteUser flags user.login SubmitResp - - else - Cmd.none - in - ( { model | deleteConfirm = cm }, cmd ) + DeleteUserNow login -> + ( { model | deleteConfirm = DimmerLoading }, Api.deleteUser flags login SubmitResp ) ---- View2 +--- View view2 : Texts -> UiSettings -> Model -> Html Msg @@ -253,27 +280,82 @@ viewTable2 texts model = ] +renderDeleteConfirm : Texts -> UiSettings -> Model -> Html Msg +renderDeleteConfirm texts settings model = + case model.deleteConfirm of + DimmerOff -> + span [ class "hidden" ] [] + + DimmerLoading -> + B.loadingDimmer + { label = "Loading..." + , active = True + } + + DimmerUserData data -> + let + empty = + List.isEmpty data.folders && data.sentMails == 0 + + folderNames = + String.join ", " data.folders + in + B.contentDimmer True <| + div [ class "flex flex-col" ] <| + (if empty then + [ div [] + [ text texts.reallyDeleteUser + ] + ] + + else + [ div [] + [ text texts.reallyDeleteUser + , text " " + , text "The following data will be deleted:" + ] + , ul [ class "list-inside list-disc" ] + [ li [ classList [ ( "hidden", List.isEmpty data.folders ) ] ] + [ text "Folders: " + , text folderNames + ] + , li [ classList [ ( "hidden", data.sentMails == 0 ) ] ] + [ text (String.fromInt data.sentMails) + , text " sent mails" + ] + ] + ] + ) + ++ [ div [ class "mt-4 flex flex-row items-center" ] + [ B.deleteButton + { label = texts.basics.yes + , icon = "fa fa-check" + , disabled = False + , handler = onClick (DeleteUserNow model.formModel.user.login) + , attrs = [ href "#" ] + } + , B.secondaryButton + { label = texts.basics.no + , icon = "fa fa-times" + , disabled = False + , handler = onClick CancelDelete + , attrs = [ href "#", class "ml-2" ] + } + ] + ] + + viewForm2 : Texts -> UiSettings -> Model -> Html Msg viewForm2 texts settings model = let newUser = Comp.UserForm.isNewUser model.formModel - - dimmerSettings : Comp.YesNoDimmer.Settings - dimmerSettings = - Comp.YesNoDimmer.defaultSettings texts.reallyDeleteUser - texts.basics.yes - texts.basics.no in Html.form [ class "flex flex-col md:relative" , onSubmit Submit ] - [ Html.map YesNoMsg - (Comp.YesNoDimmer.viewN True - dimmerSettings - model.deleteConfirm - ) + [ renderDeleteConfirm texts settings model , if newUser then h3 [ class S.header2 ] [ text texts.createNewUser @@ -331,6 +413,9 @@ viewForm2 texts settings model = FormErrorInvalid -> text texts.pleaseCorrectErrors + + FormErrorCurrentUser -> + text texts.notDeleteCurrentUser ] , B.loadingDimmer { active = model.loading diff --git a/modules/webapp/src/main/elm/Messages/Comp/UserManage.elm b/modules/webapp/src/main/elm/Messages/Comp/UserManage.elm index 60d5f054..83e73eef 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/UserManage.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/UserManage.elm @@ -30,6 +30,7 @@ type alias Texts = , basics : Messages.Basics.Texts , deleteThisUser : String , pleaseCorrectErrors : String + , notDeleteCurrentUser : String } @@ -46,6 +47,7 @@ gb = , createNewUser = "Create new user" , deleteThisUser = "Delete this user" , pleaseCorrectErrors = "Please correct the errors in the form." + , notDeleteCurrentUser = "You can't delete the user you are currently logged in with." } @@ -62,4 +64,5 @@ de = , createNewUser = "Neuen Benutzer erstellen" , deleteThisUser = "Benutzer löschen" , pleaseCorrectErrors = "Bitte korrigiere die Fehler im Formular." + , notDeleteCurrentUser = "Der aktuelle Benutzer kann nicht gelöscht werden." } diff --git a/modules/webapp/src/main/elm/Styles.elm b/modules/webapp/src/main/elm/Styles.elm index c73865fa..3bb87ebb 100644 --- a/modules/webapp/src/main/elm/Styles.elm +++ b/modules/webapp/src/main/elm/Styles.elm @@ -197,7 +197,17 @@ secondaryBasicButtonHover = deleteButton : String deleteButton = - " rounded my-auto whitespace-nowrap border border-red-500 dark:border-lightred-500 text-red-500 dark:text-orange-500 text-center px-4 py-2 shadow-none focus:outline-none focus:ring focus:ring-opacity-75 hover:bg-red-600 hover:text-white dark:hover:text-white dark:hover:bg-orange-500 dark:hover:text-bluegray-900 " + deleteButtonMain ++ deleteButtonHover + + +deleteButtonMain : String +deleteButtonMain = + " rounded my-auto whitespace-nowrap border border-red-500 dark:border-lightred-500 text-red-500 dark:text-orange-500 text-center px-4 py-2 shadow-none focus:outline-none focus:ring focus:ring-opacity-75 " + + +deleteButtonHover : String +deleteButtonHover = + " hover:bg-red-600 hover:text-white dark:hover:bg-orange-500 dark:hover:text-bluegray-900 " undeleteButton : String