diff --git a/config.yml.sample b/config.yml.sample
index 91eca777..efb52651 100644
--- a/config.yml.sample
+++ b/config.yml.sample
@@ -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.
diff --git a/docs/content/doc/development/notifications.md b/docs/content/doc/development/notifications.md
index 14906f4c..bce851e2 100644
--- a/docs/content/doc/development/notifications.md
+++ b/docs/content/doc/development/notifications.md
@@ -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.
diff --git a/pkg/cmd/user.go b/pkg/cmd/user.go
index 2c2e9900..8c4dd796 100644
--- a/pkg/cmd/user.go
+++ b/pkg/cmd/user.go
@@ -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.")
+ }
+ },
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 3d0a17df..b2e1cc95 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -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)
diff --git a/pkg/db/fixtures/lists.yml b/pkg/db/fixtures/lists.yml
index e40f92d0..a1d5e84e 100644
--- a/pkg/db/fixtures/lists.yml
+++ b/pkg/db/fixtures/lists.yml
@@ -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
diff --git a/pkg/initialize/init.go b/pkg/initialize/init.go
index d6ca0f4a..32036143 100644
--- a/pkg/initialize/init.go
+++ b/pkg/initialize/init.go
@@ -95,6 +95,8 @@ func FullInit() {
models.RegisterReminderCron()
models.RegisterOverdueReminderCron()
user.RegisterTokenCleanupCron()
+ user.RegisterDeletionNotificationCron()
+ models.RegisterUserDeletionCron()
// Start processing events
go func() {
diff --git a/pkg/migration/20210713213622.go b/pkg/migration/20210713213622.go
index 30ac6ebe..7ff8b9d8 100644
--- a/pkg/migration/20210713213622.go
+++ b/pkg/migration/20210713213622.go
@@ -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:"-"`
}
diff --git a/pkg/migration/20210725153703.go b/pkg/migration/20210725153703.go
index 2eedb69d..7c7b5097 100644
--- a/pkg/migration/20210725153703.go
+++ b/pkg/migration/20210725153703.go
@@ -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"`
}
diff --git a/pkg/migration/20210727204942.go b/pkg/migration/20210727204942.go
index 04fb82b0..c1b69025 100644
--- a/pkg/migration/20210727204942.go
+++ b/pkg/migration/20210727204942.go
@@ -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"`
}
diff --git a/pkg/migration/20210727211037.go b/pkg/migration/20210727211037.go
index 53ce74a9..37f38245 100644
--- a/pkg/migration/20210727211037.go
+++ b/pkg/migration/20210727211037.go
@@ -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"`
}
diff --git a/pkg/migration/20210802081716.go b/pkg/migration/20210802081716.go
new file mode 100644
index 00000000..74e9a0e3
--- /dev/null
+++ b/pkg/migration/20210802081716.go
@@ -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 .
+
+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
+ },
+ })
+}
diff --git a/pkg/models/list.go b/pkg/models/list.go
index 2094538d..a3ac2c97 100644
--- a/pkg/models/list.go
+++ b/pkg/models/list.go
@@ -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,
diff --git a/pkg/models/namespace.go b/pkg/models/namespace.go
index 9ae333bc..84a8ad93 100644
--- a/pkg/models/namespace.go
+++ b/pkg/models/namespace.go
@@ -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
diff --git a/pkg/models/user_delete.go b/pkg/models/user_delete.go
new file mode 100644
index 00000000..32b4d14d
--- /dev/null
+++ b/pkg/models/user_delete.go
@@ -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 .
+
+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
+}
diff --git a/pkg/models/user_delete_test.go b/pkg/models/user_delete_test.go
new file mode 100644
index 00000000..151dd2cc
--- /dev/null
+++ b/pkg/models/user_delete_test.go
@@ -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 .
+
+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)
+}
diff --git a/pkg/notifications/notification.go b/pkg/notifications/notification.go
index da40c4c1..4997a930 100644
--- a/pkg/notifications/notification.go
+++ b/pkg/notifications/notification.go
@@ -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 {
diff --git a/pkg/notifications/testing.go b/pkg/notifications/testing.go
new file mode 100644
index 00000000..b4818c45
--- /dev/null
+++ b/pkg/notifications/testing.go
@@ -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 .
+
+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.")
+}
diff --git a/pkg/routes/api/v1/info.go b/pkg/routes/api/v1/info.go
index e7ef3df3..2ae12c10 100644
--- a/pkg/routes/api/v1/info.go
+++ b/pkg/routes/api/v1/info.go
@@ -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(),
diff --git a/pkg/routes/api/v1/user_deletion.go b/pkg/routes/api/v1/user_deletion.go
new file mode 100644
index 00000000..391c761e
--- /dev/null
+++ b/pkg/routes/api/v1/user_deletion.go
@@ -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 .
+
+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."})
+}
diff --git a/pkg/routes/api/v1/user_show.go b/pkg/routes/api/v1/user_show.go
index 1277b1cb..86619f62 100644
--- a/pkg/routes/api/v1/user_show.go
+++ b/pkg/routes/api/v1/user_show.go
@@ -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)
diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go
index 7a66c8f1..678cdfe7 100644
--- a/pkg/routes/routes.go
+++ b/pkg/routes/routes.go
@@ -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{}
diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go
index e0c52082..0815bd4e 100644
--- a/pkg/swagger/docs.go
+++ b/pkg/swagger/docs.go
@@ -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"
}
diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json
index 2bc4b1ef..0d4bc75e 100644
--- a/pkg/swagger/swagger.json
+++ b/pkg/swagger/swagger.json
@@ -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"
}
diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml
index 3f5aebca..a9eb87d1 100644
--- a/pkg/swagger/swagger.yaml
+++ b/pkg/swagger/swagger.yaml
@@ -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.
+ in: body
+ name: credentials
+ required: true
+ schema:
+ $ref: '#/definitions/v1.UserDeletionRequest'
+ produces:
+ - application/json
+ 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'
+ summary: Abort a user deletion request
+ tags:
+ - user
+ /user/deletion/confirm:
+ post:
+ consumes:
+ - application/json
+ description: Confirms the deletion request of a user sent via email.
+ parameters:
+ - description: The token.
+ in: body
+ name: credentials
+ required: true
+ schema:
+ $ref: '#/definitions/v1.UserDeletionRequestConfirm'
+ produces:
+ - application/json
+ 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'
+ summary: Confirm a user deletion request
+ tags:
+ - user
+ /user/deletion/request:
+ post:
+ consumes:
+ - application/json
+ description: Requests the deletion of the current user. It will trigger an email
+ which has to be confirmed to start the deletion.
+ parameters:
+ - description: The user password.
+ in: body
+ name: credentials
+ required: true
+ schema:
+ $ref: '#/definitions/v1.UserDeletionRequest'
+ produces:
+ - application/json
+ 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'
+ summary: Request the deletion of the user
+ tags:
+ - user
/user/password:
post:
consumes:
diff --git a/pkg/user/delete.go b/pkg/user/delete.go
new file mode 100644
index 00000000..c842cec2
--- /dev/null
+++ b/pkg/user/delete.go
@@ -0,0 +1,131 @@
+// 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 .
+
+package user
+
+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"
+
+ "xorm.io/builder"
+ "xorm.io/xorm"
+)
+
+func RegisterDeletionNotificationCron() {
+ err := cron.Schedule("0 * * * *", notifyUsersScheduledForDeletion)
+ if err != nil {
+ log.Errorf("Could not register deletion cron: %s", err.Error())
+ }
+}
+
+func notifyUsersScheduledForDeletion() {
+ s := db.NewSession()
+ users := []*User{}
+ err := s.Where(builder.NotNull{"deletion_scheduled_at"}).
+ Find(&users)
+ if err != nil {
+ log.Errorf("Could not get users scheduled for deletion: %s", err)
+ return
+ }
+
+ if len(users) == 0 {
+ return
+ }
+
+ log.Debugf("Found %d users scheduled for deletion", len(users))
+
+ for _, user := range users {
+ if time.Since(user.DeletionLastReminderSent) < time.Hour*24 {
+ continue
+ }
+
+ var number = 2
+ if user.DeletionLastReminderSent.IsZero() {
+ number = 1
+ }
+ if user.DeletionScheduledAt.Sub(user.DeletionLastReminderSent) < time.Hour*24 {
+ number = 3
+ }
+
+ err = notifications.Notify(user, &AccountDeletionNotification{
+ User: user,
+ NotificationNumber: number,
+ })
+ if err != nil {
+ log.Errorf("Could not notify user %d of their deletion: %s", user.ID, err)
+ continue
+ }
+
+ user.DeletionLastReminderSent = time.Now()
+ _, err = s.Where("id = ?", user.ID).
+ Cols("deletion_last_reminder_sent").
+ Update(user)
+ if err != nil {
+ log.Errorf("Could update user %d last deletion reminder sent date: %s", user.ID, err)
+ }
+ }
+}
+
+// RequestDeletion creates a user deletion confirm token and sends a notification to the user
+func RequestDeletion(s *xorm.Session, user *User) (err error) {
+ token, err := generateNewToken(s, user, TokenAccountDeletion)
+ if err != nil {
+ return err
+ }
+
+ return notifications.Notify(user, &AccountDeletionConfirmNotification{
+ User: user,
+ ConfirmToken: token.Token,
+ })
+}
+
+// ConfirmDeletion ConformDeletion checks a token and schedules the user for deletion
+func ConfirmDeletion(s *xorm.Session, user *User, token string) (err error) {
+ tk, err := getToken(s, token, TokenAccountDeletion)
+ if err != nil {
+ return err
+ }
+
+ if tk == nil {
+ // TODO: return invalid token error
+ return
+ }
+
+ err = removeTokens(s, user, TokenAccountDeletion)
+ if err != nil {
+ return err
+ }
+
+ user.DeletionScheduledAt = time.Now().Add(3 * 24 * time.Hour)
+ _, err = s.Where("id = ?", user.ID).
+ Cols("deletion_scheduled_at").
+ Update(user)
+ return err
+}
+
+// CancelDeletion cancels the deletion of a user
+func CancelDeletion(s *xorm.Session, user *User) (err error) {
+ user.DeletionScheduledAt = time.Time{}
+ user.DeletionLastReminderSent = time.Time{}
+ _, err = s.Where("id = ?", user.ID).
+ Cols("deletion_scheduled_at", "deletion_last_reminder_sent").
+ Update(user)
+ return
+}
diff --git a/pkg/user/notifications.go b/pkg/user/notifications.go
index c57eb40e..30eb17c3 100644
--- a/pkg/user/notifications.go
+++ b/pkg/user/notifications.go
@@ -17,6 +17,8 @@
package user
import (
+ "strconv"
+
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/notifications"
)
@@ -186,3 +188,86 @@ func (n *FailedLoginAttemptNotification) ToDB() interface{} {
func (n *FailedLoginAttemptNotification) Name() string {
return "failed.login.attempt"
}
+
+// AccountDeletionConfirmNotification represents a AccountDeletionConfirmNotification notification
+type AccountDeletionConfirmNotification struct {
+ User *User
+ ConfirmToken string
+}
+
+// ToMail returns the mail notification for AccountDeletionConfirmNotification
+func (n *AccountDeletionConfirmNotification) ToMail() *notifications.Mail {
+ return notifications.NewMail().
+ Subject("Please confirm the deletion of your Vikunja account").
+ Greeting("Hi "+n.User.GetName()+",").
+ Line("You have requested the deletion of your account. To confirm this, please click the link below:").
+ Action("Confirm the deletion of my account", config.ServiceFrontendurl.GetString()+"?accountDeletionConfirm="+n.ConfirmToken).
+ Line("This link will be valid for 24 hours.").
+ Line("Once you confirm the deletion we will schedule the deletion of your account in three days and send you another email until then.").
+ Line("If you proceed with the deletion of your account, we will remove all of your namespaces, lists and tasks you created. Everything you shared with another user or team will transfer ownership to them.").
+ Line("If you did not requested the deletion or changed your mind, you can simply ignore this email.").
+ Line("Have a nice day!")
+}
+
+// ToDB returns the AccountDeletionConfirmNotification notification in a format which can be saved in the db
+func (n *AccountDeletionConfirmNotification) ToDB() interface{} {
+ return nil
+}
+
+// Name returns the name of the notification
+func (n *AccountDeletionConfirmNotification) Name() string {
+ return "user.deletion.confirm"
+}
+
+// AccountDeletionNotification represents a AccountDeletionNotification notification
+type AccountDeletionNotification struct {
+ User *User
+ NotificationNumber int
+}
+
+// ToMail returns the mail notification for AccountDeletionNotification
+func (n *AccountDeletionNotification) ToMail() *notifications.Mail {
+ return notifications.NewMail().
+ Subject("Your Vikunja account will be deleted in "+strconv.Itoa(n.NotificationNumber)+" days").
+ Greeting("Hi "+n.User.GetName()+",").
+ Line("You recently requested the deletion of your Vikunja account.").
+ Line("We will delete your account in "+strconv.Itoa(n.NotificationNumber)+" days.").
+ Line("If you changed your mind, simply click the link below to cancel the deletion and follow the instructions there:").
+ Action("Abort the deletion", config.ServiceFrontendurl.GetString()).
+ Line("Have a nice day!")
+}
+
+// ToDB returns the AccountDeletionNotification notification in a format which can be saved in the db
+func (n *AccountDeletionNotification) ToDB() interface{} {
+ return nil
+}
+
+// Name returns the name of the notification
+func (n *AccountDeletionNotification) Name() string {
+ return "user.deletion"
+}
+
+// AccountDeletedNotification represents a AccountDeletedNotification notification
+type AccountDeletedNotification struct {
+ User *User
+}
+
+// ToMail returns the mail notification for AccountDeletedNotification
+func (n *AccountDeletedNotification) ToMail() *notifications.Mail {
+ return notifications.NewMail().
+ Subject("Your Vikunja Account has been deleted").
+ Greeting("Hi " + n.User.GetName() + ",").
+ Line("As requested, we've deleted your Vikunja account.").
+ Line("This deletion is permanent. If did not create a backup and need your data back now, talk to your administrator.").
+ Line("Have a nice day!")
+}
+
+// ToDB returns the AccountDeletedNotification notification in a format which can be saved in the db
+func (n *AccountDeletedNotification) ToDB() interface{} {
+ return nil
+}
+
+// Name returns the name of the notification
+func (n *AccountDeletedNotification) Name() string {
+ return "user.deleted"
+}
diff --git a/pkg/user/token.go b/pkg/user/token.go
index 1af05efe..076df953 100644
--- a/pkg/user/token.go
+++ b/pkg/user/token.go
@@ -33,6 +33,7 @@ const (
TokenUnknown TokenKind = iota
TokenPasswordReset
TokenEmailConfirm
+ TokenAccountDeletion
tokenSize = 64
)
@@ -88,7 +89,7 @@ func RegisterTokenCleanupCron() {
defer s.Close()
deleted, err := s.
- Where("created > ? AND kind = ?", time.Now().Add(time.Hour*24*-1), TokenPasswordReset).
+ Where("created > ? AND (kind = ? OR kind = ?)", time.Now().Add(time.Hour*24*-1), TokenPasswordReset, TokenAccountDeletion).
Delete(&Token{})
if err != nil {
log.Errorf(logPrefix+"Error removing old password reset tokens: %s", err)
diff --git a/pkg/user/user.go b/pkg/user/user.go
index a97bbb58..cec27b5a 100644
--- a/pkg/user/user.go
+++ b/pkg/user/user.go
@@ -95,6 +95,9 @@ type User struct {
DefaultListID int64 `xorm:"bigint null index" json:"-"`
WeekStart int `xorm:"null" json:"-"`
+ DeletionScheduledAt time.Time `xorm:"datetime null" json:"-"`
+ DeletionLastReminderSent time.Time `xorm:"datetime null" json:"-"`
+
// A timestamp when this task was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
// A timestamp when this task was last updated. You cannot change this value.
@@ -367,6 +370,16 @@ func CheckUserPassword(user *User, password string) error {
return nil
}
+// GetCurrentUserFromDB gets a user from jwt claims and returns the full user from the db.
+func GetCurrentUserFromDB(s *xorm.Session, c echo.Context) (user *User, err error) {
+ u, err := GetCurrentUser(c)
+ if err != nil {
+ return nil, err
+ }
+
+ return GetUserByID(s, u.ID)
+}
+
// GetCurrentUser returns the current user based on its jwt token
func GetCurrentUser(c echo.Context) (user *User, err error) {
jwtinf := c.Get("user").(*jwt.Token)