feat: Implement account deletion (mark for removal) feature
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 23s
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 23s
- Added `marked_for_deletion` and `marked_for_deletion_at` fields to User model (Python and TypeScript) with serialization updates - Created POST /api/user/mark-for-deletion endpoint with JWT auth, error handling, and SSE event trigger - Blocked login and password reset for marked users; added new error codes ACCOUNT_MARKED_FOR_DELETION and ALREADY_MARKED - Updated UserProfile.vue with "Delete My Account" button, confirmation modal (email input), loading state, success/error modals, and sign-out/redirect logic - Synced error codes and model fields between backend and frontend - Added and updated backend and frontend tests to cover all flows and edge cases - All Acceptance Criteria from the spec are complete and verified
This commit is contained in:
@@ -12,7 +12,7 @@ from config.paths import get_user_image_dir
|
||||
|
||||
from api.error_codes import MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING, \
|
||||
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD, INVALID_CREDENTIALS, \
|
||||
NOT_VERIFIED
|
||||
NOT_VERIFIED, ACCOUNT_MARKED_FOR_DELETION
|
||||
from db.db import users_db
|
||||
from api.utils import normalize_email
|
||||
|
||||
@@ -146,6 +146,10 @@ def login():
|
||||
if not user.verified:
|
||||
return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403
|
||||
|
||||
# Block login for marked accounts
|
||||
if user.marked_for_deletion:
|
||||
return jsonify({'error': 'This account has been marked for deletion and cannot be accessed.', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
|
||||
|
||||
payload = {
|
||||
'email': norm_email,
|
||||
'user_id': user.id,
|
||||
@@ -196,12 +200,14 @@ def request_password_reset():
|
||||
user_dict = users_db.get(UserQuery.email == norm_email)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if user:
|
||||
token = secrets.token_urlsafe(32)
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
user.reset_token = token
|
||||
user.reset_token_created = now_iso
|
||||
users_db.update(user.to_dict(), UserQuery.email == norm_email)
|
||||
send_reset_password_email(norm_email, token)
|
||||
# Silently ignore reset requests for marked accounts (don't leak account status)
|
||||
if not user.marked_for_deletion:
|
||||
token = secrets.token_urlsafe(32)
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
user.reset_token = token
|
||||
user.reset_token_created = now_iso
|
||||
users_db.update(user.to_dict(), UserQuery.email == norm_email)
|
||||
send_reset_password_email(norm_email, token)
|
||||
|
||||
return jsonify({'message': success_msg}), 200
|
||||
|
||||
|
||||
@@ -9,4 +9,6 @@ USER_NOT_FOUND = "USER_NOT_FOUND"
|
||||
ALREADY_VERIFIED = "ALREADY_VERIFIED"
|
||||
MISSING_EMAIL_OR_PASSWORD = "MISSING_EMAIL_OR_PASSWORD"
|
||||
INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
|
||||
NOT_VERIFIED = "NOT_VERIFIED"
|
||||
NOT_VERIFIED = "NOT_VERIFIED"
|
||||
ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION"
|
||||
ALREADY_MARKED = "ALREADY_MARKED"
|
||||
@@ -1,4 +1,5 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from events.types.user_modified import UserModified
|
||||
from models.user import User
|
||||
from tinydb import Query
|
||||
from db.db import users_db
|
||||
@@ -6,8 +7,11 @@ import jwt
|
||||
import random
|
||||
import string
|
||||
import utils.email_sender as email_sender
|
||||
from datetime import datetime, timedelta
|
||||
from api.utils import get_validated_user_id
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from api.utils import get_validated_user_id, normalize_email, send_event_for_current_user
|
||||
from api.error_codes import ACCOUNT_MARKED_FOR_DELETION, ALREADY_MARKED
|
||||
from events.types.event_types import EventType
|
||||
from events.types.event import Event
|
||||
|
||||
user_api = Blueprint('user_api', __name__)
|
||||
UserQuery = Query()
|
||||
@@ -170,3 +174,36 @@ def set_pin():
|
||||
user.pin_setup_code_created = None
|
||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||
return jsonify({'message': 'Parent PIN set'}), 200
|
||||
|
||||
@user_api.route('/user/mark-for-deletion', methods=['POST'])
|
||||
def mark_for_deletion():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
# Validate email from request body
|
||||
data = request.get_json()
|
||||
email = data.get('email', '').strip()
|
||||
if not email:
|
||||
return jsonify({'error': 'Email is required', 'code': 'EMAIL_REQUIRED'}), 400
|
||||
|
||||
# Verify email matches the logged-in user - make sure to normalize the email address first
|
||||
if normalize_email(email) != normalize_email(user.email):
|
||||
return jsonify({'error': 'Email does not match your account', 'code': 'EMAIL_MISMATCH'}), 400
|
||||
|
||||
# Check if already marked
|
||||
if user.marked_for_deletion:
|
||||
return jsonify({'error': 'Account already marked for deletion', 'code': ALREADY_MARKED}), 400
|
||||
|
||||
# Mark for deletion
|
||||
user.marked_for_deletion = True
|
||||
user.marked_for_deletion_at = datetime.now(timezone.utc).isoformat()
|
||||
users_db.update(user.to_dict(), UserQuery.id == user.id)
|
||||
|
||||
# Trigger SSE event
|
||||
send_event_for_current_user(Event(EventType.USER_MARKED_FOR_DELETION.value, UserModified(user.id, UserModified.OPERATION_DELETE)))
|
||||
|
||||
return jsonify({'success': True}), 200
|
||||
|
||||
Reference in New Issue
Block a user