User account deletion (#937)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#937
Co-authored-by: konrad <konrad@kola-entertainments.de>
Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2021-08-11 19:08:10 +00:00
parent cd21c5fc6e
commit 27119ad6d4
28 changed files with 1402 additions and 41 deletions

View File

@ -43,6 +43,10 @@ service:
# If enabled, vikunja will send an email to everyone who is either assigned to a task or created it when a task reminder
# is due.
enableemailreminders: true
# If true, will allow users to request the complete deletion of their account. When using external authentication methods
# it may be required to coordinate with them in order to delete the account. This setting will not affect the cli commands
# for user deletion.
enableuserdeletion: true
database:
# Database type to use. Supported types are mysql, postgres and sqlite.

View File

@ -108,6 +108,13 @@ return
The `mail` package provides a `Fake()` method which you should call in the `MainTest` functions of your package.
If it was called, no mails are being sent and you can instead assert they have been sent with the `AssertSent` method.
When testing, you should call the `notifications.Fake()` method in the `TestMain` function of the package you want to test.
This prevents any notifications from being sent and lets you assert a notifications has been sent like this:
{{< highlight golang >}}
notifications.AssertSent(t, &ReminderDueNotification{})
{{< /highlight >}}
## Example
Take a look at the [pkg/user/notifications.go](https://code.vikunja.io/api/src/branch/main/pkg/user/notifications.go) file for a good example.

View File

@ -17,6 +17,7 @@
package cmd
import (
"bufio"
"fmt"
"os"
"strconv"
@ -42,6 +43,7 @@ var (
userFlagResetPasswordDirectly bool
userFlagEnableUser bool
userFlagDisableUser bool
userFlagDeleteNow bool
)
func init() {
@ -66,7 +68,10 @@ func init() {
userChangeEnabledCmd.Flags().BoolVarP(&userFlagDisableUser, "disable", "d", false, "Disable the user.")
userChangeEnabledCmd.Flags().BoolVarP(&userFlagEnableUser, "enable", "e", false, "Enable the user.")
userCmd.AddCommand(userListCmd, userCreateCmd, userUpdateCmd, userResetPasswordCmd, userChangeEnabledCmd)
// User deletion flags
userDeleteCmd.Flags().BoolVarP(&userFlagDeleteNow, "now", "n", false, "If provided, deletes the user immediately instead of sending them an email first.")
userCmd.AddCommand(userListCmd, userCreateCmd, userUpdateCmd, userResetPasswordCmd, userChangeEnabledCmd, userDeleteCmd)
rootCmd.AddCommand(userCmd)
}
@ -300,3 +305,61 @@ var userChangeEnabledCmd = &cobra.Command{
fmt.Printf("User status successfully changed, status is now \"%s\"\n", u.Status)
},
}
var userDeleteCmd = &cobra.Command{
Use: "delete [user id]",
Short: "Delete an existing user.",
Long: "Kick off the user deletion process. If call without the --now flag, this command will only trigger an email to the user in order for them to confirm and start the deletion process. With the flag the user is deleted immediately. USE WITH CAUTION.",
Args: cobra.ExactArgs(1),
PreRun: func(cmd *cobra.Command, args []string) {
initialize.FullInit()
},
Run: func(cmd *cobra.Command, args []string) {
if userFlagDeleteNow {
fmt.Println("You requested to delete the user immediately. Are you sure?")
fmt.Println(`To confirm, please type "yes, I confirm" in all uppercase:`)
cr := bufio.NewReader(os.Stdin)
text, err := cr.ReadString('\n')
if err != nil {
log.Fatalf("could not read confirmation message: %s", err)
}
if text != "YES, I CONFIRM\n" {
log.Fatalf("invalid confirmation message")
}
}
s := db.NewSession()
defer s.Close()
if err := s.Begin(); err != nil {
log.Fatalf("Count not start transaction: %s", err)
}
u := getUserFromArg(s, args[0])
if userFlagDeleteNow {
err := models.DeleteUser(s, u)
if err != nil {
_ = s.Rollback()
log.Fatalf("Error removing the user: %s", err)
}
} else {
err := user.RequestDeletion(s, u)
if err != nil {
_ = s.Rollback()
log.Fatalf("Could not request user deletion: %s", err)
}
}
if err := s.Commit(); err != nil {
log.Fatalf("Error saving everything: %s", err)
}
if userFlagDeleteNow {
fmt.Println("User deleted successfully.")
} else {
fmt.Println("User scheduled for deletion successfully.")
}
},
}

View File

@ -56,6 +56,7 @@ const (
ServiceSentryDsn Key = `service.sentrydsn`
ServiceTestingtoken Key = `service.testingtoken`
ServiceEnableEmailReminders Key = `service.enableemailreminders`
ServiceEnableUserDeletion Key = `service.enableuserdeletion`
AuthLocalEnabled Key = `auth.local.enabled`
AuthOpenIDEnabled Key = `auth.openid.enabled`
@ -246,6 +247,7 @@ func InitDefaultConfig() {
ServiceEnableTaskComments.setDefault(true)
ServiceEnableTotp.setDefault(true)
ServiceEnableEmailReminders.setDefault(true)
ServiceEnableUserDeletion.setDefault(true)
// Auth
AuthLocalEnabled.setDefault(true)

View File

@ -232,3 +232,13 @@
position: 23
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
-
id: 24
title: Test24
description: Lorem Ipsum
identifier: test6
owner_id: 6
namespace_id: 6
position: 7
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12

View File

@ -95,6 +95,8 @@ func FullInit() {
models.RegisterReminderCron()
models.RegisterOverdueReminderCron()
user.RegisterTokenCleanupCron()
user.RegisterDeletionNotificationCron()
models.RegisterUserDeletionCron()
// Start processing events
go func() {

View File

@ -22,7 +22,7 @@ import (
)
type users20210713213622 struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
ID int64 `xorm:"bigint autoincr not null" json:"id"`
IsActive bool `xorm:"null" json:"-"`
Status int `xorm:"default 0" json:"-"`
}

View File

@ -24,7 +24,7 @@ import (
)
type tasks20210725153703 struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"listtask"`
ID int64 `xorm:"bigint autoincr not null" json:"id" param:"listtask"`
Position float64 `xorm:"double null" json:"position"`
KanbanPosition float64 `xorm:"double null" json:"kanban_position"`
}

View File

@ -24,7 +24,7 @@ import (
)
type lists20210727204942 struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"list"`
ID int64 `xorm:"bigint autoincr not null" json:"id" param:"list"`
Position float64 `xorm:"double null" json:"position"`
}

View File

@ -24,7 +24,7 @@ import (
)
type buckets20210727211037 struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"list"`
ID int64 `xorm:"bigint autoincr not null" json:"id" param:"list"`
Position float64 `xorm:"double null" json:"position"`
}

View File

@ -0,0 +1,46 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"time"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type users20210802081716 struct {
DeletionScheduledAt time.Time `xorm:"datetime null" json:"-"`
DeletionLastReminderSent time.Time `xorm:"datetime null" json:"-"`
}
func (users20210802081716) TableName() string {
return "users"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20210802081716",
Description: "Add account deletion schedule timestamps",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(users20210802081716{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -762,11 +762,19 @@ func (l *List) Delete(s *xorm.Session, a web.Auth) (err error) {
}
// Delete all tasks on that list
_, err = s.Where("list_id = ?", l.ID).Delete(&Task{})
// Using the loop to make sure all related entities to all tasks are properly deleted as well.
tasks, _, _, err := getRawTasksForLists(s, []*List{l}, a, &taskOptions{})
if err != nil {
return
}
for _, task := range tasks {
err = task.Delete(s, a)
if err != nil {
return err
}
}
return events.Dispatch(&ListDeletedEvent{
List: l,
Doer: a,

View File

@ -650,7 +650,10 @@ func CreateNewNamespaceForUser(s *xorm.Session, user *user.User) (err error) {
// @Failure 500 {object} models.Message "Internal error"
// @Router /namespaces/{id} [delete]
func (n *Namespace) Delete(s *xorm.Session, a web.Auth) (err error) {
return deleteNamespace(s, n, a, true)
}
func deleteNamespace(s *xorm.Session, n *Namespace, a web.Auth, withLists bool) (err error) {
// Check if the namespace exists
_, err = GetNamespaceByID(s, n.ID)
if err != nil {
@ -663,6 +666,15 @@ func (n *Namespace) Delete(s *xorm.Session, a web.Auth) (err error) {
return
}
namespaceDeleted := &NamespaceDeletedEvent{
Namespace: n,
Doer: a,
}
if !withLists {
return events.Dispatch(namespaceDeleted)
}
// Delete all lists with their tasks
lists, err := GetListsByNamespaceID(s, n.ID, &user.User{})
if err != nil {
@ -670,43 +682,18 @@ func (n *Namespace) Delete(s *xorm.Session, a web.Auth) (err error) {
}
if len(lists) == 0 {
return events.Dispatch(&NamespaceDeletedEvent{
Namespace: n,
Doer: a,
})
return events.Dispatch(namespaceDeleted)
}
var listIDs []int64
// We need to do that for here because we need the list ids to delete two times:
// 1) to delete the lists itself
// 2) to delete the list tasks
for _, l := range lists {
listIDs = append(listIDs, l.ID)
// Looping over all lists to let the list handle properly cleaning up the tasks and everything else associated with it.
for _, list := range lists {
err = list.Delete(s, a)
if err != nil {
return err
}
}
if len(listIDs) == 0 {
return events.Dispatch(&NamespaceDeletedEvent{
Namespace: n,
Doer: a,
})
}
// Delete tasks
_, err = s.In("list_id", listIDs).Delete(&Task{})
if err != nil {
return
}
// Delete the lists
_, err = s.In("id", listIDs).Delete(&List{})
if err != nil {
return
}
return events.Dispatch(&NamespaceDeletedEvent{
Namespace: n,
Doer: a,
})
return events.Dispatch(namespaceDeleted)
}
// Update implements the update method via the interface

275
pkg/models/user_delete.go Normal file
View File

@ -0,0 +1,275 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"time"
"code.vikunja.io/api/pkg/cron"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/user"
"xorm.io/builder"
"xorm.io/xorm"
)
// User deletion must happen here in this packaage because we want to delete everything associated to this user.
// Because most of these things are managed in the models package, using them has to happen here.
// RegisterUserDeletionCron registers the cron job that actually removes users who are scheduled to delete.
func RegisterUserDeletionCron() {
err := cron.Schedule("0 * * * *", deleteUsers)
if err != nil {
log.Errorf("Could not register deletion cron: %s", err.Error())
}
}
func deleteUsers() {
s := db.NewSession()
users := []*user.User{}
err := s.Where(builder.Lt{"deletion_scheduled_at": time.Now()}).
Find(&users)
if err != nil {
log.Errorf("Could not get users scheduled for deletion: %s", err)
return
}
if len(users) == 0 {
return
}
for _, u := range users {
if u.DeletionScheduledAt.Before(time.Now()) {
log.Debugf("User %d is not yet scheduled for deletion.", u.ID)
continue
}
err = s.Begin()
if err != nil {
log.Errorf("Could not start transaction: %s", err)
return
}
err = DeleteUser(s, u)
if err != nil {
_ = s.Rollback()
log.Errorf("Could not delete u %d: %s", u.ID, err)
return
}
err = s.Commit()
if err != nil {
log.Errorf("Could not commit transaction: %s", err)
return
}
}
}
// DeleteUser completely removes a user and all their associated lists, namespaces and tasks.
// This action is irrevocable.
// Public to allow deletion from the CLI.
func DeleteUser(s *xorm.Session, u *user.User) (err error) {
namespacesToDelete := []*Namespace{}
// Get all namespaces and lists this u has access to
nm := &Namespace{IsArchived: true}
res, _, _, err := nm.ReadAll(s, u, "", 1, -1)
if err != nil {
return err
}
namespaces := res.([]*NamespaceWithLists)
for _, n := range namespaces {
if n.ID < 0 {
continue
}
hadUsers, err := ensureNamespaceAdminUser(s, &n.Namespace)
if err != nil {
return err
}
if hadUsers {
continue
}
hadTeams, err := ensureNamespaceAdminTeam(s, &n.Namespace)
if err != nil {
return err
}
if hadTeams {
continue
}
namespacesToDelete = append(namespacesToDelete, &n.Namespace)
}
// Get all lists to delete
listsToDelete := []*List{}
lm := &List{IsArchived: true}
res, _, _, err = lm.ReadAll(s, u, "", 0, -1)
if err != nil {
return err
}
lists := res.([]*List)
for _, l := range lists {
if l.ID < 0 {
continue
}
hadUsers, err := ensureListAdminUser(s, l)
if err != nil {
return err
}
if hadUsers {
continue
}
hadTeams, err := ensureListAdminTeam(s, l)
if err != nil {
return err
}
if hadTeams {
continue
}
listsToDelete = append(listsToDelete, l)
}
// Delete everything not shared with anybody else
for _, n := range namespacesToDelete {
err = deleteNamespace(s, n, u, false)
if err != nil {
return err
}
}
for _, l := range listsToDelete {
err = l.Delete(s, u)
if err != nil {
return err
}
}
_, err = s.Where("id = ?", u.ID).Delete(u)
if err != nil {
return err
}
return notifications.Notify(u, &user.AccountDeletedNotification{})
}
func ensureNamespaceAdminUser(s *xorm.Session, n *Namespace) (hadUsers bool, err error) {
namespaceUsers := []*NamespaceUser{}
err = s.Where("namespace_id = ?", n.ID).Find(&namespaceUsers)
if err != nil {
return
}
if len(namespaceUsers) == 0 {
return false, nil
}
for _, lu := range namespaceUsers {
if lu.Right == RightAdmin {
// List already has more than one admin, no need to do anything
return true, nil
}
}
firstUser := namespaceUsers[0]
firstUser.Right = RightAdmin
_, err = s.Where("id = ?", firstUser.ID).
Cols("right").
Update(firstUser)
return true, err
}
func ensureNamespaceAdminTeam(s *xorm.Session, n *Namespace) (hadTeams bool, err error) {
namespaceTeams := []*TeamNamespace{}
err = s.Where("namespace_id = ?", n.ID).Find(&namespaceTeams)
if err != nil {
return
}
if len(namespaceTeams) == 0 {
return false, nil
}
for _, lu := range namespaceTeams {
if lu.Right == RightAdmin {
// List already has more than one admin, no need to do anything
return true, nil
}
}
firstTeam := namespaceTeams[0]
firstTeam.Right = RightAdmin
_, err = s.Where("id = ?", firstTeam.ID).
Cols("right").
Update(firstTeam)
return true, err
}
func ensureListAdminUser(s *xorm.Session, l *List) (hadUsers bool, err error) {
listUsers := []*ListUser{}
err = s.Where("list_id = ?", l.ID).Find(&listUsers)
if err != nil {
return
}
if len(listUsers) == 0 {
return false, nil
}
for _, lu := range listUsers {
if lu.Right == RightAdmin {
// List already has more than one admin, no need to do anything
return true, nil
}
}
firstUser := listUsers[0]
firstUser.Right = RightAdmin
_, err = s.Where("id = ?", firstUser.ID).
Cols("right").
Update(firstUser)
return true, err
}
func ensureListAdminTeam(s *xorm.Session, l *List) (hadTeams bool, err error) {
listTeams := []*TeamList{}
err = s.Where("list_id = ?", l.ID).Find(&listTeams)
if err != nil {
return
}
if len(listTeams) == 0 {
return false, nil
}
for _, lu := range listTeams {
if lu.Right == RightAdmin {
// List already has more than one admin, no need to do anything
return true, nil
}
}
firstTeam := listTeams[0]
firstTeam.Right = RightAdmin
_, err = s.Where("id = ?", firstTeam.ID).
Cols("right").
Update(firstTeam)
return true, err
}

View File

@ -0,0 +1,47 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert"
)
func TestDeleteUser(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
notifications.Fake()
u := &user.User{ID: 6}
err := DeleteUser(s, u)
assert.NoError(t, err)
db.AssertMissing(t, "users", map[string]interface{}{"id": u.ID})
db.AssertMissing(t, "lists", map[string]interface{}{"id": 24}) // only user6 had access to this list
db.AssertExists(t, "lists", map[string]interface{}{"id": 6}, false)
db.AssertExists(t, "lists", map[string]interface{}{"id": 7}, false)
db.AssertExists(t, "lists", map[string]interface{}{"id": 8}, false)
db.AssertExists(t, "lists", map[string]interface{}{"id": 9}, false)
db.AssertExists(t, "lists", map[string]interface{}{"id": 10}, false)
db.AssertExists(t, "lists", map[string]interface{}{"id": 11}, false)
}

View File

@ -48,6 +48,10 @@ type Notifiable interface {
// Notify notifies a notifiable of a notification
func Notify(notifiable Notifiable, notification Notification) (err error) {
if isUnderTest {
sentTestNotifications = append(sentTestNotifications, notification)
return nil
}
err = notifyMail(notifiable, notification)
if err != nil {

View File

@ -0,0 +1,46 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notifications
import (
"testing"
"github.com/stretchr/testify/assert"
)
var (
isUnderTest bool
sentTestNotifications []Notification
)
func Fake() {
isUnderTest = true
sentTestNotifications = nil
}
// AssertSent asserts a notification has been sent
func AssertSent(t *testing.T, n Notification) {
var found bool
for _, testNotification := range sentTestNotifications {
if n.Name() == testNotification.Name() {
found = true
break
}
}
assert.True(t, found, "Failed to assert "+n.Name()+" has been sent.")
}

View File

@ -48,6 +48,7 @@ type vikunjaInfos struct {
CaldavEnabled bool `json:"caldav_enabled"`
AuthInfo authInfo `json:"auth"`
EmailRemindersEnabled bool `json:"email_reminders_enabled"`
UserDeletionEnabled bool `json:"user_deletion_enabled"`
}
type authInfo struct {
@ -89,6 +90,7 @@ func Info(c echo.Context) error {
TotpEnabled: config.ServiceEnableTotp.GetBool(),
CaldavEnabled: config.ServiceEnableCaldav.GetBool(),
EmailRemindersEnabled: config.ServiceEnableEmailReminders.GetBool(),
UserDeletionEnabled: config.ServiceEnableUserDeletion.GetBool(),
Legal: legalInfo{
ImprintURL: config.LegalImprintURL.GetString(),
PrivacyPolicyURL: config.LegalPrivacyURL.GetString(),

View File

@ -0,0 +1,201 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package v1
import (
"net/http"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web/handler"
"github.com/labstack/echo/v4"
)
type UserDeletionRequest struct {
Password string `json:"password" valid:"required"`
}
type UserDeletionRequestConfirm struct {
Token string `json:"token" valid:"required"`
}
// UserRequestDeletion is the handler to request a user deletion process (sends a mail)
// @Summary Request the deletion of the user
// @Description Requests the deletion of the current user. It will trigger an email which has to be confirmed to start the deletion.
// @tags user
// @Accept json
// @Produce json
// @Param credentials body v1.UserDeletionRequest true "The user password."
// @Success 200 {object} models.Message
// @Failure 412 {object} web.HTTPError "Bad password provided."
// @Failure 500 {object} models.Message "Internal error"
// @Router /user/deletion/request [post]
func UserRequestDeletion(c echo.Context) error {
var deletionRequest UserDeletionRequest
if err := c.Bind(&deletionRequest); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "No password provided.")
}
err := c.Validate(deletionRequest)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
s := db.NewSession()
defer s.Close()
err = s.Begin()
if err != nil {
return handler.HandleHTTPError(err, c)
}
u, err := user.GetCurrentUserFromDB(s, c)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = user.CheckUserPassword(u, deletionRequest.Password)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = user.RequestDeletion(s, u)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = s.Commit()
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
return c.JSON(http.StatusOK, models.Message{Message: "Successfully requested deletion."})
}
// UserConfirmDeletion is the handler to confirm a user deletion process and start it
// @Summary Confirm a user deletion request
// @Description Confirms the deletion request of a user sent via email.
// @tags user
// @Accept json
// @Produce json
// @Param credentials body v1.UserDeletionRequestConfirm true "The token."
// @Success 200 {object} models.Message
// @Failure 412 {object} web.HTTPError "Bad token provided."
// @Failure 500 {object} models.Message "Internal error"
// @Router /user/deletion/confirm [post]
func UserConfirmDeletion(c echo.Context) error {
var deleteConfirmation UserDeletionRequestConfirm
if err := c.Bind(&deleteConfirmation); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "No token provided.")
}
err := c.Validate(deleteConfirmation)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
s := db.NewSession()
defer s.Close()
err = s.Begin()
if err != nil {
return handler.HandleHTTPError(err, c)
}
u, err := user.GetCurrentUserFromDB(s, c)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = user.ConfirmDeletion(s, u, deleteConfirmation.Token)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = s.Commit()
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
return c.JSON(http.StatusNoContent, models.Message{Message: "Successfully confirmed the deletion request."})
}
// UserCancelDeletion is the handler to abort a user deletion process
// @Summary Abort a user deletion request
// @Description Aborts an in-progress user deletion.
// @tags user
// @Accept json
// @Produce json
// @Param credentials body v1.UserDeletionRequest true "The user password to confirm."
// @Success 200 {object} models.Message
// @Failure 412 {object} web.HTTPError "Bad password provided."
// @Failure 500 {object} models.Message "Internal error"
// @Router /user/deletion/cancel [post]
func UserCancelDeletion(c echo.Context) error {
var deletionRequest UserDeletionRequest
if err := c.Bind(&deletionRequest); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "No password provided.")
}
err := c.Validate(deletionRequest)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
s := db.NewSession()
defer s.Close()
err = s.Begin()
if err != nil {
return handler.HandleHTTPError(err, c)
}
u, err := user.GetCurrentUserFromDB(s, c)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = user.CheckUserPassword(u, deletionRequest.Password)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = user.CancelDeletion(s, u)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = s.Commit()
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
return c.JSON(http.StatusNoContent, models.Message{Message: "Successfully confirmed the deletion request."})
}

View File

@ -18,6 +18,7 @@ package v1
import (
"net/http"
"time"
"code.vikunja.io/api/pkg/user"
@ -32,7 +33,8 @@ import (
type userWithSettings struct {
user.User
Settings *UserSettings `json:"settings"`
Settings *UserSettings `json:"settings"`
DeletionScheduledAt time.Time `json:"deletion_scheduled_at"`
}
// UserShow gets all informations about the current user
@ -71,6 +73,7 @@ func UserShow(c echo.Context) error {
DefaultListID: u.DefaultListID,
WeekStart: u.WeekStart,
},
DeletionScheduledAt: u.DeletionScheduledAt,
}
return c.JSON(http.StatusOK, us)

View File

@ -312,6 +312,13 @@ func registerAPIRoutes(a *echo.Group) {
u.GET("/settings/totp/qrcode", apiv1.UserTOTPQrCode)
}
// User deletion
if config.ServiceEnableUserDeletion.GetBool() {
u.POST("/deletion/request", apiv1.UserRequestDeletion)
u.POST("/deletion/confirm", apiv1.UserConfirmDeletion)
u.POST("/deletion/cancel", apiv1.UserCancelDeletion)
}
listHandler := &handler.WebHandler{
EmptyStruct: func() handler.CObject {
return &models.List{}

View File

@ -6315,6 +6315,144 @@ var doc = `{
}
}
},
"/user/deletion/cancel": {
"post": {
"description": "Aborts an in-progress user deletion.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Abort a user deletion request",
"parameters": [
{
"description": "The user password to confirm.",
"name": "credentials",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.UserDeletionRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"412": {
"description": "Bad password provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/user/deletion/confirm": {
"post": {
"description": "Confirms the deletion request of a user sent via email.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Confirm a user deletion request",
"parameters": [
{
"description": "The token.",
"name": "credentials",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.UserDeletionRequestConfirm"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"412": {
"description": "Bad token provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/user/deletion/request": {
"post": {
"description": "Requests the deletion of the current user. It will trigger an email which has to be confirmed to start the deletion.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Request the deletion of the user",
"parameters": [
{
"description": "The user password.",
"name": "credentials",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.UserDeletionRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"412": {
"description": "Bad password provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/user/password": {
"post": {
"security": [
@ -8556,6 +8694,22 @@ var doc = `{
}
}
},
"v1.UserDeletionRequest": {
"type": "object",
"properties": {
"password": {
"type": "string"
}
}
},
"v1.UserDeletionRequestConfirm": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
},
"v1.UserPassword": {
"type": "object",
"properties": {
@ -8695,6 +8849,9 @@ var doc = `{
"totp_enabled": {
"type": "boolean"
},
"user_deletion_enabled": {
"type": "boolean"
},
"version": {
"type": "string"
}

View File

@ -6298,6 +6298,144 @@
}
}
},
"/user/deletion/cancel": {
"post": {
"description": "Aborts an in-progress user deletion.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Abort a user deletion request",
"parameters": [
{
"description": "The user password to confirm.",
"name": "credentials",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.UserDeletionRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"412": {
"description": "Bad password provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/user/deletion/confirm": {
"post": {
"description": "Confirms the deletion request of a user sent via email.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Confirm a user deletion request",
"parameters": [
{
"description": "The token.",
"name": "credentials",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.UserDeletionRequestConfirm"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"412": {
"description": "Bad token provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/user/deletion/request": {
"post": {
"description": "Requests the deletion of the current user. It will trigger an email which has to be confirmed to start the deletion.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Request the deletion of the user",
"parameters": [
{
"description": "The user password.",
"name": "credentials",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.UserDeletionRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"412": {
"description": "Bad password provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/user/password": {
"post": {
"security": [
@ -8539,6 +8677,22 @@
}
}
},
"v1.UserDeletionRequest": {
"type": "object",
"properties": {
"password": {
"type": "string"
}
}
},
"v1.UserDeletionRequestConfirm": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
},
"v1.UserPassword": {
"type": "object",
"properties": {
@ -8678,6 +8832,9 @@
"totp_enabled": {
"type": "boolean"
},
"user_deletion_enabled": {
"type": "boolean"
},
"version": {
"type": "string"
}

View File

@ -1185,6 +1185,16 @@ definitions:
email), `upload`, `initials`, `default`.
type: string
type: object
v1.UserDeletionRequest:
properties:
password:
type: string
type: object
v1.UserDeletionRequestConfirm:
properties:
token:
type: string
type: object
v1.UserPassword:
properties:
new_password:
@ -1284,6 +1294,8 @@ definitions:
type: boolean
totp_enabled:
type: boolean
user_deletion_enabled:
type: boolean
version:
type: string
type: object
@ -5531,6 +5543,97 @@ paths:
summary: Confirm the email of a new user
tags:
- user
/user/deletion/cancel:
post:
consumes:
- application/json
description: Aborts an in-progress user deletion.
parameters:
- description: The user password to confirm.