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
|
||||
|
||||
@@ -13,3 +13,5 @@ class EventType(Enum):
|
||||
CHILD_REWARD_REQUEST = "child_reward_request"
|
||||
|
||||
CHILD_MODIFIED = "child_modified"
|
||||
|
||||
USER_MARKED_FOR_DELETION = "user_marked_for_deletion"
|
||||
|
||||
21
backend/events/types/user_modified.py
Normal file
21
backend/events/types/user_modified.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class UserModified(Payload):
|
||||
OPERATION_ADD = "ADD"
|
||||
OPERATION_EDIT = "EDIT"
|
||||
OPERATION_DELETE = "DELETE"
|
||||
def __init__(self, user_id: str, operation: str):
|
||||
super().__init__({
|
||||
'user_id': user_id,
|
||||
'operation': operation,
|
||||
})
|
||||
|
||||
@property
|
||||
def user_id(self) -> str:
|
||||
return self.get("user_id")
|
||||
|
||||
@property
|
||||
def operation(self) -> str:
|
||||
return self.get("operation")
|
||||
|
||||
@@ -16,6 +16,8 @@ class User(BaseModel):
|
||||
pin: str = ''
|
||||
pin_setup_code: str = ''
|
||||
pin_setup_code_created: str | None = None
|
||||
marked_for_deletion: bool = False
|
||||
marked_for_deletion_at: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
@@ -33,6 +35,8 @@ class User(BaseModel):
|
||||
pin=d.get('pin', ''),
|
||||
pin_setup_code=d.get('pin_setup_code', ''),
|
||||
pin_setup_code_created=d.get('pin_setup_code_created'),
|
||||
marked_for_deletion=d.get('marked_for_deletion', False),
|
||||
marked_for_deletion_at=d.get('marked_for_deletion_at'),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at')
|
||||
@@ -54,6 +58,8 @@ class User(BaseModel):
|
||||
'image_id': self.image_id,
|
||||
'pin': self.pin,
|
||||
'pin_setup_code': self.pin_setup_code,
|
||||
'pin_setup_code_created': self.pin_setup_code_created
|
||||
'pin_setup_code_created': self.pin_setup_code_created,
|
||||
'marked_for_deletion': self.marked_for_deletion,
|
||||
'marked_for_deletion_at': self.marked_for_deletion_at
|
||||
})
|
||||
return base
|
||||
|
||||
177
backend/tests/test_user_api.py
Normal file
177
backend/tests/test_user_api.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from flask import Flask
|
||||
from api.user_api import user_api
|
||||
from api.auth_api import auth_api
|
||||
from db.db import users_db
|
||||
from tinydb import Query
|
||||
import jwt
|
||||
|
||||
# Test user credentials
|
||||
TEST_EMAIL = "usertest@example.com"
|
||||
TEST_PASSWORD = "testpass123"
|
||||
MARKED_EMAIL = "marked@example.com"
|
||||
MARKED_PASSWORD = "markedpass"
|
||||
|
||||
def add_test_users():
|
||||
"""Add test users to the database."""
|
||||
# Remove if exists
|
||||
users_db.remove(Query().email == TEST_EMAIL)
|
||||
users_db.remove(Query().email == MARKED_EMAIL)
|
||||
|
||||
# Add regular test user
|
||||
users_db.insert({
|
||||
"id": "test_user_id",
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"email": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD,
|
||||
"verified": True,
|
||||
"image_id": "boy01",
|
||||
"marked_for_deletion": False,
|
||||
"marked_for_deletion_at": None
|
||||
})
|
||||
|
||||
# Add user already marked for deletion
|
||||
users_db.insert({
|
||||
"id": "marked_user_id",
|
||||
"first_name": "Marked",
|
||||
"last_name": "User",
|
||||
"email": MARKED_EMAIL,
|
||||
"password": MARKED_PASSWORD,
|
||||
"verified": True,
|
||||
"image_id": "girl01",
|
||||
"marked_for_deletion": True,
|
||||
"marked_for_deletion_at": "2024-01-15T10:30:00+00:00"
|
||||
})
|
||||
|
||||
def login_and_get_token(client, email, password):
|
||||
"""Login and extract JWT token from response."""
|
||||
resp = client.post('/login', json={"email": email, "password": password})
|
||||
assert resp.status_code == 200
|
||||
# Extract token from Set-Cookie header
|
||||
set_cookie = resp.headers.get("Set-Cookie")
|
||||
assert set_cookie and "token=" in set_cookie
|
||||
# Flask test client automatically handles cookies
|
||||
return resp
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Setup Flask test client with registered blueprints."""
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(user_api)
|
||||
app.register_blueprint(auth_api)
|
||||
app.config['TESTING'] = True
|
||||
app.config['SECRET_KEY'] = 'supersecretkey'
|
||||
app.config['FRONTEND_URL'] = 'http://localhost:5173' # Needed for email_sender
|
||||
with app.test_client() as client:
|
||||
add_test_users()
|
||||
yield client
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client(client):
|
||||
"""Setup client with authenticated user session."""
|
||||
login_and_get_token(client, TEST_EMAIL, TEST_PASSWORD)
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def marked_client(client):
|
||||
"""Setup client with marked-for-deletion user session."""
|
||||
login_and_get_token(client, MARKED_EMAIL, MARKED_PASSWORD)
|
||||
return client
|
||||
|
||||
def test_mark_user_for_deletion_success(authenticated_client):
|
||||
"""Test successfully marking a user account for deletion."""
|
||||
response = authenticated_client.post('/user/mark-for-deletion', json={"email": TEST_EMAIL})
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['success'] is True
|
||||
|
||||
# Verify database was updated
|
||||
UserQuery = Query()
|
||||
user = users_db.search(UserQuery.email == TEST_EMAIL)[0]
|
||||
assert user['marked_for_deletion'] is True
|
||||
assert user['marked_for_deletion_at'] is not None
|
||||
|
||||
# Verify timestamp is valid ISO format
|
||||
marked_at = datetime.fromisoformat(user['marked_for_deletion_at'])
|
||||
assert marked_at.tzinfo is not None
|
||||
|
||||
def test_login_for_marked_user_returns_403(client):
|
||||
"""Test that login for a marked-for-deletion user returns 403 Forbidden."""
|
||||
response = client.post('/login', json={
|
||||
"email": MARKED_EMAIL,
|
||||
"password": MARKED_PASSWORD
|
||||
})
|
||||
assert response.status_code == 403
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
assert data['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
||||
|
||||
def test_mark_for_deletion_requires_auth(client):
|
||||
"""Test that marking for deletion requires authentication."""
|
||||
response = client.post('/user/mark-for-deletion', json={"email": TEST_EMAIL})
|
||||
assert response.status_code == 401
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
|
||||
def test_login_blocked_for_marked_user(client):
|
||||
"""Test that login is blocked for users marked for deletion."""
|
||||
response = client.post('/login', json={
|
||||
"email": MARKED_EMAIL,
|
||||
"password": MARKED_PASSWORD
|
||||
})
|
||||
assert response.status_code == 403
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
assert data['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
||||
|
||||
def test_login_succeeds_for_unmarked_user(client):
|
||||
"""Test that login works normally for users not marked for deletion."""
|
||||
response = client.post('/login', json={
|
||||
"email": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'message' in data
|
||||
|
||||
def test_password_reset_ignored_for_marked_user(client):
|
||||
"""Test that password reset requests are silently ignored for marked users."""
|
||||
response = client.post('/request-password-reset', json={"email": MARKED_EMAIL})
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'message' in data
|
||||
|
||||
def test_password_reset_works_for_unmarked_user(client):
|
||||
"""Test that password reset works normally for unmarked users."""
|
||||
response = client.post('/request-password-reset', json={"email": TEST_EMAIL})
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'message' in data
|
||||
|
||||
def test_mark_for_deletion_updates_timestamp(authenticated_client):
|
||||
"""Test that marking for deletion sets a proper timestamp."""
|
||||
before_time = datetime.now(timezone.utc)
|
||||
|
||||
response = authenticated_client.post('/user/mark-for-deletion', json={"email": TEST_EMAIL})
|
||||
assert response.status_code == 200
|
||||
|
||||
after_time = datetime.now(timezone.utc)
|
||||
|
||||
# Verify timestamp is between before and after
|
||||
UserQuery = Query()
|
||||
user = users_db.search(UserQuery.email == TEST_EMAIL)[0]
|
||||
marked_at = datetime.fromisoformat(user['marked_for_deletion_at'])
|
||||
|
||||
assert before_time <= marked_at <= after_time
|
||||
|
||||
def test_mark_for_deletion_with_invalid_jwt(client):
|
||||
"""Test marking for deletion with invalid JWT token."""
|
||||
# Set invalid cookie manually
|
||||
client.set_cookie('token', 'invalid.jwt.token')
|
||||
|
||||
response = client.post('/user/mark-for-deletion', json={})
|
||||
assert response.status_code == 401
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
Reference in New Issue
Block a user