diff --git a/pkg/routes/api/v2/user_deletion.go b/pkg/routes/api/v2/user_deletion.go
new file mode 100644
index 000000000..1ecc5e009
--- /dev/null
+++ b/pkg/routes/api/v2/user_deletion.go
@@ -0,0 +1,172 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present 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 License 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 License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+ "xorm.io/xorm"
+)
+
+type userDeletionPasswordBody struct {
+ Body struct {
+ Password string `json:"password" doc:"The authenticated user's password. Required for local users; ignored for users authenticated via an external provider."`
+ }
+}
+
+type userDeletionConfirmBody struct {
+ Body struct {
+ Token string `json:"token" required:"true" minLength:"1" doc:"The deletion confirmation token from the email sent by the request-deletion endpoint."`
+ }
+}
+
+func RegisterUserDeletionRoutes(api huma.API) {
+ tags := []string{"user"}
+
+ Register(api, huma.Operation{
+ OperationID: "user-deletion-request",
+ Summary: "Request account deletion",
+ Description: "Starts deletion of the authenticated user's account. Local users must provide their password; a confirmation email is then sent and deletion only proceeds once confirmed.",
+ Method: http.MethodPost,
+ Path: "/user/deletion/request",
+ Tags: tags,
+ DefaultStatus: http.StatusNoContent,
+ }, userDeletionRequest)
+
+ Register(api, huma.Operation{
+ OperationID: "user-deletion-confirm",
+ Summary: "Confirm account deletion",
+ Description: "Confirms a requested account deletion using the token from the confirmation email and schedules the account for deletion.",
+ Method: http.MethodPost,
+ Path: "/user/deletion/confirm",
+ Tags: tags,
+ DefaultStatus: http.StatusNoContent,
+ }, userDeletionConfirm)
+
+ Register(api, huma.Operation{
+ OperationID: "user-deletion-cancel",
+ Summary: "Cancel account deletion",
+ Description: "Cancels a scheduled account deletion. Local users must provide their password.",
+ Method: http.MethodPost,
+ Path: "/user/deletion/cancel",
+ Tags: tags,
+ DefaultStatus: http.StatusNoContent,
+ }, userDeletionCancel)
+}
+
+func init() { AddRouteRegistrar(RegisterUserDeletionRoutes) }
+
+// authUserFromCtx resolves the full DB user for the authenticated caller, refusing
+// link shares (which have no account to delete) with a 403.
+func authUserFromCtx(ctx context.Context, s *xorm.Session) (*user.User, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ authUser, is := a.(*user.User)
+ if !is {
+ return nil, huma.Error403Forbidden("only users can manage account deletion")
+ }
+ // The auth user from the JWT claims is partial; re-fetch for the password hash.
+ u, err := user.GetUserByID(s, authUser.ID)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return u, nil
+}
+
+func userDeletionRequest(ctx context.Context, in *userDeletionPasswordBody) (*emptyBody, error) {
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := authUserFromCtx(ctx, s)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ if u.IsLocalUser() {
+ if err := user.CheckUserPassword(u, in.Body.Password); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ }
+
+ if err := user.RequestDeletion(s, u); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
+
+func userDeletionConfirm(ctx context.Context, in *userDeletionConfirmBody) (*emptyBody, error) {
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := authUserFromCtx(ctx, s)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ if err := user.ConfirmDeletion(s, u, in.Body.Token); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
+
+func userDeletionCancel(ctx context.Context, in *userDeletionPasswordBody) (*emptyBody, error) {
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := authUserFromCtx(ctx, s)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ if u.IsLocalUser() {
+ if err := user.CheckUserPassword(u, in.Body.Password); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ }
+
+ if err := user.CancelDeletion(s, u); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
diff --git a/pkg/webtests/huma_user_deletion_test.go b/pkg/webtests/huma_user_deletion_test.go
new file mode 100644
index 000000000..081db594d
--- /dev/null
+++ b/pkg/webtests/huma_user_deletion_test.go
@@ -0,0 +1,194 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present 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 License 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 License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package webtests
+
+import (
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/auth"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ userDeletionRequestPath = "/api/v2/user/deletion/request"
+ userDeletionConfirmPath = "/api/v2/user/deletion/confirm"
+ userDeletionCancelPath = "/api/v2/user/deletion/cancel"
+ // testUserPassword is the plaintext password for every local fixture user.
+ testUserPassword = "12345678"
+)
+
+// deletionTokenFor reads the cleartext account-deletion token RequestDeletion
+// stored for the user. RequestDeletion only mails the token, so the test pulls
+// it straight from user_tokens (kind 3 = TokenAccountDeletion).
+func deletionTokenFor(t *testing.T, userID int64) string {
+ t.Helper()
+ s := db.NewSession()
+ defer s.Close()
+ tok := struct {
+ Token string `xorm:"token"`
+ }{}
+ has, err := s.Table("user_tokens").
+ Where("user_id = ? AND kind = ?", userID, 3).
+ Get(&tok)
+ require.NoError(t, err)
+ require.True(t, has, "RequestDeletion must have stored a deletion token for user %d", userID)
+ return tok.Token
+}
+
+func deletionScheduledFor(t *testing.T, userID int64) bool {
+ t.Helper()
+ s := db.NewSession()
+ defer s.Close()
+ u, err := user.GetUserByID(s, userID)
+ require.NoError(t, err)
+ return !u.DeletionScheduledAt.IsZero()
+}
+
+// TestHumaUserDeletion ports v1's account-deletion flow (request → confirm →
+// cancel) to v2. v1 returned 200/204 with a confirmation message body; v2
+// normalises all three to an empty 204 (the action returns no resource), so
+// every success here asserts 204 + empty body.
+func TestHumaUserDeletion(t *testing.T) {
+ t.Run("Request - wrong password rejected", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"wrong"}`, token, "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ assert.False(t, deletionScheduledFor(t, testuser1.ID), "a rejected request must not schedule deletion")
+ })
+
+ t.Run("Confirm - invalid token rejected", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, `{"token":"not-a-real-token"}`, token, "")
+ assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
+ assert.False(t, deletionScheduledFor(t, testuser1.ID))
+ })
+
+ t.Run("Confirm - missing token is a validation error", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, `{"token":""}`, token, "")
+ assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Request then confirm schedules deletion", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "")
+ require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String())
+ assert.Empty(t, req.Body.String(), "v2 normalises the request action to an empty 204")
+ assert.False(t, deletionScheduledFor(t, testuser1.ID), "request alone must not schedule; confirmation does")
+
+ confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath,
+ `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "")
+ require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String())
+ assert.Empty(t, confirm.Body.String())
+ assert.True(t, deletionScheduledFor(t, testuser1.ID), "confirm must schedule the deletion")
+ })
+
+ t.Run("Cancel - wrong password rejected", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Schedule first so there is something to cancel.
+ req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "")
+ require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String())
+ confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath,
+ `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "")
+ require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String())
+ require.True(t, deletionScheduledFor(t, testuser1.ID))
+
+ cancel := humaRequest(t, e, http.MethodPost, userDeletionCancelPath, `{"password":"wrong"}`, token, "")
+ assert.Equal(t, http.StatusForbidden, cancel.Code, "body: %s", cancel.Body.String())
+ assert.True(t, deletionScheduledFor(t, testuser1.ID), "a rejected cancel must leave the deletion scheduled")
+ })
+
+ t.Run("Cancel - correct password clears the schedule", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "")
+ require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String())
+ confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath,
+ `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "")
+ require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String())
+ require.True(t, deletionScheduledFor(t, testuser1.ID))
+
+ cancel := humaRequest(t, e, http.MethodPost, userDeletionCancelPath, `{"password":"`+testUserPassword+`"}`, token, "")
+ require.Equal(t, http.StatusNoContent, cancel.Code, "body: %s", cancel.Body.String())
+ assert.Empty(t, cancel.Body.String())
+ assert.False(t, deletionScheduledFor(t, testuser1.ID), "cancel must clear the scheduled deletion")
+ })
+
+ t.Run("Unauthenticated", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ for _, path := range []string{userDeletionRequestPath, userDeletionConfirmPath, userDeletionCancelPath} {
+ rec := humaRequest(t, e, http.MethodPost, path, `{}`, "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "%s body: %s", path, rec.Body.String())
+ }
+ })
+}
+
+// TestHumaUserDeletion_LinkShareForbidden asserts a link share — which has no
+// account — is refused (403) on every deletion action.
+func TestHumaUserDeletion_LinkShareForbidden(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token, err := auth.NewLinkShareJWTAuthtoken(&models.LinkSharing{
+ ID: 1,
+ Hash: "test",
+ ProjectID: 1,
+ Permission: models.PermissionRead,
+ SharingType: models.SharingTypeWithoutPassword,
+ SharedByID: 1,
+ })
+ require.NoError(t, err)
+
+ for _, tc := range []struct {
+ name string
+ path string
+ body string
+ }{
+ {"request", userDeletionRequestPath, `{"password":"` + testUserPassword + `"}`},
+ {"confirm", userDeletionConfirmPath, `{"token":"x"}`},
+ {"cancel", userDeletionCancelPath, `{"password":"` + testUserPassword + `"}`},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPost, tc.path, tc.body, token, "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ }
+}