Merge pull request from eikek/fix/delete-user

Fix/delete user
This commit is contained in:
mergify[bot] 2021-09-08 19:11:50 +00:00 committed by GitHub
commit 84e16f65f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 410 additions and 60 deletions
modules
backend/src/main/scala/docspell/backend/ops
restapi/src/main/resources
restserver/src/main/scala/docspell/restserver/routes
store/src/main/scala/docspell/store
webapp/src/main/elm

@ -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
@ -207,16 +214,24 @@ 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))
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))

@ -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

@ -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
}
}

@ -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)
}

@ -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] =

@ -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
}

@ -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)
}

@ -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))

@ -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)
}

@ -51,6 +51,7 @@ module Api exposing
, getCollectiveSettings
, getContacts
, getCustomFields
, getDeleteUserData
, getEquipment
, getEquipments
, getFolderDetail
@ -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 =
}
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
, expect = Http.expectJson receive Api.Model.DeleteUserData.decoder
}
--- Job Queue

@ -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
]

@ -85,6 +85,7 @@ getUser model =
, email = model.email
, state = state
, password = model.password
, source = "local"
}

@ -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

@ -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."
}

@ -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