Implement account deletion handling and improve user feedback
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Has been cancelled
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Has been cancelled
- Added checks for accounts marked for deletion in signup, verification, and password reset processes. - Updated reward and task listing to sort user-created items first. - Enhanced user API to clear verification and reset tokens when marking accounts for deletion. - Introduced tests for marked accounts to ensure proper handling in various scenarios. - Updated profile and reward edit components to reflect changes in validation and data handling.
This commit is contained in:
@@ -39,7 +39,11 @@ def signup():
|
|||||||
email = data.get('email', '')
|
email = data.get('email', '')
|
||||||
norm_email = normalize_email(email)
|
norm_email = normalize_email(email)
|
||||||
|
|
||||||
if users_db.search(UserQuery.email == norm_email):
|
existing = users_db.get(UserQuery.email == norm_email)
|
||||||
|
if existing:
|
||||||
|
user = User.from_dict(existing)
|
||||||
|
if user.marked_for_deletion:
|
||||||
|
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
|
||||||
return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400
|
return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400
|
||||||
|
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
@@ -78,6 +82,10 @@ def verify():
|
|||||||
status = 'error'
|
status = 'error'
|
||||||
reason = 'Invalid token'
|
reason = 'Invalid token'
|
||||||
code = INVALID_TOKEN
|
code = INVALID_TOKEN
|
||||||
|
elif user.marked_for_deletion:
|
||||||
|
status = 'error'
|
||||||
|
reason = 'Account marked for deletion'
|
||||||
|
code = ACCOUNT_MARKED_FOR_DELETION
|
||||||
else:
|
else:
|
||||||
created_str = user.verify_token_created
|
created_str = user.verify_token_created
|
||||||
if not created_str:
|
if not created_str:
|
||||||
@@ -175,6 +183,8 @@ def me():
|
|||||||
user = User.from_dict(user_dict) if user_dict else None
|
user = User.from_dict(user_dict) if user_dict else None
|
||||||
if not user:
|
if not user:
|
||||||
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
||||||
|
if user.marked_for_deletion:
|
||||||
|
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'email': user.email,
|
'email': user.email,
|
||||||
'id': user_id,
|
'id': user_id,
|
||||||
@@ -201,8 +211,8 @@ def request_password_reset():
|
|||||||
user_dict = users_db.get(UserQuery.email == norm_email)
|
user_dict = users_db.get(UserQuery.email == norm_email)
|
||||||
user = User.from_dict(user_dict) if user_dict else None
|
user = User.from_dict(user_dict) if user_dict else None
|
||||||
if user:
|
if user:
|
||||||
# Silently ignore reset requests for marked accounts (don't leak account status)
|
if user.marked_for_deletion:
|
||||||
if not user.marked_for_deletion:
|
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
now_iso = datetime.utcnow().isoformat()
|
now_iso = datetime.utcnow().isoformat()
|
||||||
user.reset_token = token
|
user.reset_token = token
|
||||||
|
|||||||
@@ -65,7 +65,13 @@ def list_rewards():
|
|||||||
if r.get('user_id') is None and r['name'].strip().lower() in user_rewards:
|
if r.get('user_id') is None and r['name'].strip().lower() in user_rewards:
|
||||||
continue # Skip default if user version exists
|
continue # Skip default if user version exists
|
||||||
filtered_rewards.append(r)
|
filtered_rewards.append(r)
|
||||||
return jsonify({'rewards': filtered_rewards}), 200
|
|
||||||
|
# Sort: user-created items first (by name), then default items (by name)
|
||||||
|
user_created = sorted([r for r in filtered_rewards if r.get('user_id') == user_id], key=lambda x: x['name'].lower())
|
||||||
|
default_items = sorted([r for r in filtered_rewards if r.get('user_id') is None], key=lambda x: x['name'].lower())
|
||||||
|
sorted_rewards = user_created + default_items
|
||||||
|
|
||||||
|
return jsonify({'rewards': sorted_rewards}), 200
|
||||||
|
|
||||||
@reward_api.route('/reward/<id>', methods=['DELETE'])
|
@reward_api.route('/reward/<id>', methods=['DELETE'])
|
||||||
def delete_reward(id):
|
def delete_reward(id):
|
||||||
|
|||||||
@@ -63,7 +63,13 @@ def list_tasks():
|
|||||||
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
|
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
|
||||||
continue # Skip default if user version exists
|
continue # Skip default if user version exists
|
||||||
filtered_tasks.append(t)
|
filtered_tasks.append(t)
|
||||||
return jsonify({'tasks': filtered_tasks}), 200
|
|
||||||
|
# Sort: user-created items first (by name), then default items (by name)
|
||||||
|
user_created = sorted([t for t in filtered_tasks if t.get('user_id') == user_id], key=lambda x: x['name'].lower())
|
||||||
|
default_items = sorted([t for t in filtered_tasks if t.get('user_id') is None], key=lambda x: x['name'].lower())
|
||||||
|
sorted_tasks = user_created + default_items
|
||||||
|
|
||||||
|
return jsonify({'tasks': sorted_tasks}), 200
|
||||||
|
|
||||||
@task_api.route('/task/<id>', methods=['DELETE'])
|
@task_api.route('/task/<id>', methods=['DELETE'])
|
||||||
def delete_task(id):
|
def delete_task(id):
|
||||||
|
|||||||
@@ -231,6 +231,13 @@ def mark_for_deletion():
|
|||||||
# Mark for deletion
|
# Mark for deletion
|
||||||
user.marked_for_deletion = True
|
user.marked_for_deletion = True
|
||||||
user.marked_for_deletion_at = datetime.now(timezone.utc).isoformat()
|
user.marked_for_deletion_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
# Invalidate any outstanding verification/reset tokens so they cannot be used after marking
|
||||||
|
user.verify_token = None
|
||||||
|
user.verify_token_created = None
|
||||||
|
user.reset_token = None
|
||||||
|
user.reset_token_created = None
|
||||||
|
|
||||||
users_db.update(user.to_dict(), UserQuery.id == user.id)
|
users_db.update(user.to_dict(), UserQuery.id == user.id)
|
||||||
|
|
||||||
# Trigger SSE event
|
# Trigger SSE event
|
||||||
|
|||||||
82
backend/tests/test_auth_api_marked.py
Normal file
82
backend/tests/test_auth_api_marked.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
from api.auth_api import auth_api
|
||||||
|
from db.db import users_db
|
||||||
|
from tinydb import Query
|
||||||
|
from models.user import User
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.config['SECRET_KEY'] = 'supersecretkey'
|
||||||
|
with app.test_client() as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
def setup_marked_user(email, verified=False, verify_token=None, reset_token=None):
|
||||||
|
users_db.remove(Query().email == email)
|
||||||
|
user = User(
|
||||||
|
first_name='Marked',
|
||||||
|
last_name='User',
|
||||||
|
email=email,
|
||||||
|
password=generate_password_hash('password123'),
|
||||||
|
verified=verified,
|
||||||
|
marked_for_deletion=True,
|
||||||
|
verify_token=verify_token,
|
||||||
|
verify_token_created=datetime.utcnow().isoformat() if verify_token else None,
|
||||||
|
reset_token=reset_token,
|
||||||
|
reset_token_created=datetime.utcnow().isoformat() if reset_token else None
|
||||||
|
)
|
||||||
|
users_db.insert(user.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def test_signup_marked_for_deletion(client):
|
||||||
|
setup_marked_user('marked@example.com')
|
||||||
|
data = {
|
||||||
|
'first_name': 'Marked',
|
||||||
|
'last_name': 'User',
|
||||||
|
'email': 'marked@example.com',
|
||||||
|
'password': 'password123'
|
||||||
|
}
|
||||||
|
response = client.post('/auth/signup', json=data)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
||||||
|
|
||||||
|
def test_verify_marked_for_deletion(client):
|
||||||
|
setup_marked_user('marked2@example.com', verify_token='verifytoken123')
|
||||||
|
response = client.get('/auth/verify', query_string={'token': 'verifytoken123'})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
||||||
|
|
||||||
|
def test_request_password_reset_marked_for_deletion(client):
|
||||||
|
setup_marked_user('marked3@example.com')
|
||||||
|
response = client.post('/auth/request-password-reset', json={'email': 'marked3@example.com'})
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
||||||
|
|
||||||
|
def test_me_marked_for_deletion(client):
|
||||||
|
email = 'marked4@example.com'
|
||||||
|
setup_marked_user(email, verified=True)
|
||||||
|
|
||||||
|
# Get the user to access the ID
|
||||||
|
user_dict = users_db.get(Query().email == email)
|
||||||
|
user = User.from_dict(user_dict)
|
||||||
|
|
||||||
|
# Create a valid JWT token for the marked user
|
||||||
|
payload = {
|
||||||
|
'email': email,
|
||||||
|
'user_id': user.id,
|
||||||
|
'exp': datetime.utcnow() + timedelta(hours=24)
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, 'supersecretkey', algorithm='HS256')
|
||||||
|
|
||||||
|
# Make request with token cookie
|
||||||
|
client.set_cookie('token', token)
|
||||||
|
response = client.get('/auth/me')
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
||||||
@@ -15,6 +15,7 @@ from utils.account_deletion_scheduler import (
|
|||||||
delete_user_data,
|
delete_user_data,
|
||||||
process_deletion_queue,
|
process_deletion_queue,
|
||||||
check_interrupted_deletions,
|
check_interrupted_deletions,
|
||||||
|
trigger_deletion_manually,
|
||||||
MAX_DELETION_ATTEMPTS
|
MAX_DELETION_ATTEMPTS
|
||||||
)
|
)
|
||||||
from models.user import User
|
from models.user import User
|
||||||
@@ -953,3 +954,163 @@ class TestIntegration:
|
|||||||
assert users_db.get(Query_.id == user_id) is None
|
assert users_db.get(Query_.id == user_id) is None
|
||||||
assert child_db.get(Query_.id == child_id) is None
|
assert child_db.get(Query_.id == child_id) is None
|
||||||
assert not os.path.exists(user_image_dir)
|
assert not os.path.exists(user_image_dir)
|
||||||
|
|
||||||
|
|
||||||
|
class TestManualDeletionTrigger:
|
||||||
|
"""Tests for manually triggered deletion (admin endpoint)."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Clear test databases before each test."""
|
||||||
|
users_db.truncate()
|
||||||
|
child_db.truncate()
|
||||||
|
task_db.truncate()
|
||||||
|
reward_db.truncate()
|
||||||
|
image_db.truncate()
|
||||||
|
pending_reward_db.truncate()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
"""Clean up test directories after each test."""
|
||||||
|
for user_id in ['manual_user_1', 'manual_user_2', 'manual_user_3', 'manual_user_retry', 'recent_user']:
|
||||||
|
user_dir = get_user_image_dir(user_id)
|
||||||
|
if os.path.exists(user_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(user_dir)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_manual_trigger_deletes_immediately(self):
|
||||||
|
"""Test that manual trigger deletes users marked recently (not past threshold)."""
|
||||||
|
user_id = 'manual_user_1'
|
||||||
|
|
||||||
|
# Create user marked only 1 hour ago (well before 720 hour threshold)
|
||||||
|
marked_time = (datetime.now() - timedelta(hours=1)).isoformat()
|
||||||
|
user = User(
|
||||||
|
id=user_id,
|
||||||
|
email='manual1@example.com',
|
||||||
|
first_name='Manual',
|
||||||
|
last_name='Test',
|
||||||
|
password='hash',
|
||||||
|
marked_for_deletion=True,
|
||||||
|
marked_for_deletion_at=marked_time,
|
||||||
|
deletion_in_progress=False,
|
||||||
|
deletion_attempted_at=None
|
||||||
|
)
|
||||||
|
users_db.insert(user.to_dict())
|
||||||
|
|
||||||
|
# Verify user is NOT due for deletion under normal circumstances
|
||||||
|
assert is_user_due_for_deletion(user) is False
|
||||||
|
|
||||||
|
# Manually trigger deletion
|
||||||
|
result = trigger_deletion_manually()
|
||||||
|
|
||||||
|
# Verify user was deleted despite not being past threshold
|
||||||
|
Query_ = Query()
|
||||||
|
assert users_db.get(Query_.id == user_id) is None
|
||||||
|
assert result['triggered'] is True
|
||||||
|
|
||||||
|
def test_manual_trigger_deletes_multiple_users(self):
|
||||||
|
"""Test that manual trigger deletes all marked users regardless of time."""
|
||||||
|
# Create multiple users marked at different times
|
||||||
|
users_data = [
|
||||||
|
('manual_user_1', 1), # 1 hour ago
|
||||||
|
('manual_user_2', 100), # 100 hours ago
|
||||||
|
('manual_user_3', 800), # 800 hours ago (past threshold)
|
||||||
|
]
|
||||||
|
|
||||||
|
for user_id, hours_ago in users_data:
|
||||||
|
marked_time = (datetime.now() - timedelta(hours=hours_ago)).isoformat()
|
||||||
|
user = User(
|
||||||
|
id=user_id,
|
||||||
|
email=f'{user_id}@example.com',
|
||||||
|
first_name='Manual',
|
||||||
|
last_name='Test',
|
||||||
|
password='hash',
|
||||||
|
marked_for_deletion=True,
|
||||||
|
marked_for_deletion_at=marked_time,
|
||||||
|
deletion_in_progress=False,
|
||||||
|
deletion_attempted_at=None
|
||||||
|
)
|
||||||
|
users_db.insert(user.to_dict())
|
||||||
|
|
||||||
|
# Verify only one is due under normal circumstances
|
||||||
|
all_users = users_db.all()
|
||||||
|
due_count = sum(1 for u in all_users if is_user_due_for_deletion(User.from_dict(u)))
|
||||||
|
assert due_count == 1 # Only the 800 hour old one
|
||||||
|
|
||||||
|
# Manually trigger deletion
|
||||||
|
trigger_deletion_manually()
|
||||||
|
|
||||||
|
# Verify ALL marked users were deleted
|
||||||
|
Query_ = Query()
|
||||||
|
assert len(users_db.all()) == 0
|
||||||
|
|
||||||
|
def test_manual_trigger_respects_retry_limit(self):
|
||||||
|
"""Test that manual trigger still respects max retry limit."""
|
||||||
|
user_id = 'manual_user_retry'
|
||||||
|
|
||||||
|
# Create user marked recently with max attempts already
|
||||||
|
marked_time = (datetime.now() - timedelta(hours=1)).isoformat()
|
||||||
|
attempted_time = (datetime.now() - timedelta(hours=1)).isoformat()
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
id=user_id,
|
||||||
|
email='retry@example.com',
|
||||||
|
first_name='Retry',
|
||||||
|
last_name='Test',
|
||||||
|
password='hash',
|
||||||
|
marked_for_deletion=True,
|
||||||
|
marked_for_deletion_at=marked_time,
|
||||||
|
deletion_in_progress=False,
|
||||||
|
deletion_attempted_at=attempted_time # Has 1 attempt
|
||||||
|
)
|
||||||
|
users_db.insert(user.to_dict())
|
||||||
|
|
||||||
|
# Mock delete_user_data to fail consistently
|
||||||
|
with patch('utils.account_deletion_scheduler.delete_user_data', return_value=False):
|
||||||
|
# Trigger multiple times to exceed retry limit
|
||||||
|
for _ in range(MAX_DELETION_ATTEMPTS):
|
||||||
|
trigger_deletion_manually()
|
||||||
|
|
||||||
|
# User should still exist after max attempts
|
||||||
|
Query_ = Query()
|
||||||
|
remaining_user = users_db.get(Query_.id == user_id)
|
||||||
|
assert remaining_user is not None
|
||||||
|
|
||||||
|
def test_manual_trigger_with_no_marked_users(self):
|
||||||
|
"""Test that manual trigger handles empty queue gracefully."""
|
||||||
|
result = trigger_deletion_manually()
|
||||||
|
|
||||||
|
assert result['triggered'] is True
|
||||||
|
assert result['queued_users'] == 0
|
||||||
|
|
||||||
|
def test_normal_scheduler_still_respects_threshold(self):
|
||||||
|
"""Test that normal scheduler run (force=False) still respects time threshold."""
|
||||||
|
user_id = 'recent_user'
|
||||||
|
|
||||||
|
# Create user marked only 1 hour ago
|
||||||
|
marked_time = (datetime.now() - timedelta(hours=1)).isoformat()
|
||||||
|
user = User(
|
||||||
|
id=user_id,
|
||||||
|
email='recent@example.com',
|
||||||
|
first_name='Recent',
|
||||||
|
last_name='Test',
|
||||||
|
password='hash',
|
||||||
|
marked_for_deletion=True,
|
||||||
|
marked_for_deletion_at=marked_time,
|
||||||
|
deletion_in_progress=False,
|
||||||
|
deletion_attempted_at=None
|
||||||
|
)
|
||||||
|
users_db.insert(user.to_dict())
|
||||||
|
|
||||||
|
# Run normal scheduler (not manual trigger)
|
||||||
|
process_deletion_queue(force=False)
|
||||||
|
|
||||||
|
# User should still exist because not past threshold
|
||||||
|
Query_ = Query()
|
||||||
|
assert users_db.get(Query_.id == user_id) is not None
|
||||||
|
|
||||||
|
# Now run with force=True
|
||||||
|
process_deletion_queue(force=True)
|
||||||
|
|
||||||
|
# User should be deleted
|
||||||
|
assert users_db.get(Query_.id == user_id) is None
|
||||||
|
|||||||
@@ -138,11 +138,12 @@ def test_login_succeeds_for_unmarked_user(client):
|
|||||||
assert 'message' in data
|
assert 'message' in data
|
||||||
|
|
||||||
def test_password_reset_ignored_for_marked_user(client):
|
def test_password_reset_ignored_for_marked_user(client):
|
||||||
"""Test that password reset requests are silently ignored for marked users."""
|
"""Test that password reset requests return 403 for marked users."""
|
||||||
response = client.post('/request-password-reset', json={"email": MARKED_EMAIL})
|
response = client.post('/request-password-reset', json={"email": MARKED_EMAIL})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 403
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
assert 'message' in data
|
assert 'error' in data
|
||||||
|
assert data['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
||||||
|
|
||||||
def test_password_reset_works_for_unmarked_user(client):
|
def test_password_reset_works_for_unmarked_user(client):
|
||||||
"""Test that password reset works normally for unmarked users."""
|
"""Test that password reset works normally for unmarked users."""
|
||||||
@@ -167,6 +168,35 @@ def test_mark_for_deletion_updates_timestamp(authenticated_client):
|
|||||||
|
|
||||||
assert before_time <= marked_at <= after_time
|
assert before_time <= marked_at <= after_time
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_for_deletion_clears_tokens(authenticated_client):
|
||||||
|
"""When an account is marked for deletion, verify/reset tokens must be cleared."""
|
||||||
|
# Seed verify/reset tokens for the user
|
||||||
|
UserQuery = Query()
|
||||||
|
now_iso = datetime.utcnow().isoformat()
|
||||||
|
users_db.update({
|
||||||
|
'verify_token': 'verify-abc',
|
||||||
|
'verify_token_created': now_iso,
|
||||||
|
'reset_token': 'reset-xyz',
|
||||||
|
'reset_token_created': now_iso
|
||||||
|
}, UserQuery.email == TEST_EMAIL)
|
||||||
|
|
||||||
|
# Ensure tokens are present before marking
|
||||||
|
user_before = users_db.search(UserQuery.email == TEST_EMAIL)[0]
|
||||||
|
assert user_before['verify_token'] is not None
|
||||||
|
assert user_before['reset_token'] is not None
|
||||||
|
|
||||||
|
# Mark account for deletion
|
||||||
|
response = authenticated_client.post('/user/mark-for-deletion', json={"email": TEST_EMAIL})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Verify tokens were cleared in the DB
|
||||||
|
user_after = users_db.search(UserQuery.email == TEST_EMAIL)[0]
|
||||||
|
assert user_after.get('verify_token') is None
|
||||||
|
assert user_after.get('verify_token_created') is None
|
||||||
|
assert user_after.get('reset_token') is None
|
||||||
|
assert user_after.get('reset_token_created') is None
|
||||||
|
|
||||||
def test_mark_for_deletion_with_invalid_jwt(client):
|
def test_mark_for_deletion_with_invalid_jwt(client):
|
||||||
"""Test marking for deletion with invalid JWT token."""
|
"""Test marking for deletion with invalid JWT token."""
|
||||||
# Set invalid cookie manually
|
# Set invalid cookie manually
|
||||||
|
|||||||
@@ -210,10 +210,17 @@ def delete_user_data(user: User) -> bool:
|
|||||||
pass
|
pass
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def process_deletion_queue():
|
def process_deletion_queue(force=False):
|
||||||
"""
|
"""
|
||||||
Process the deletion queue: find users due for deletion and delete them.
|
Process the deletion queue: find users due for deletion and delete them.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force (bool): If True, delete all marked users immediately without checking threshold.
|
||||||
|
If False, only delete users past the threshold time.
|
||||||
"""
|
"""
|
||||||
|
if force:
|
||||||
|
logger.info("Starting FORCED deletion scheduler run (bypassing time threshold)")
|
||||||
|
else:
|
||||||
logger.info("Starting deletion scheduler run")
|
logger.info("Starting deletion scheduler run")
|
||||||
|
|
||||||
processed = 0
|
processed = 0
|
||||||
@@ -235,8 +242,8 @@ def process_deletion_queue():
|
|||||||
user = User.from_dict(user_dict)
|
user = User.from_dict(user_dict)
|
||||||
processed += 1
|
processed += 1
|
||||||
|
|
||||||
# Check if user is due for deletion
|
# Check if user is due for deletion (skip check if force=True)
|
||||||
if not is_user_due_for_deletion(user):
|
if not force and not is_user_due_for_deletion(user):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check retry limit
|
# Check retry limit
|
||||||
@@ -346,10 +353,11 @@ def stop_deletion_scheduler():
|
|||||||
def trigger_deletion_manually():
|
def trigger_deletion_manually():
|
||||||
"""
|
"""
|
||||||
Manually trigger the deletion process (for admin use).
|
Manually trigger the deletion process (for admin use).
|
||||||
|
Deletes all marked users immediately without waiting for threshold.
|
||||||
Returns stats about the run.
|
Returns stats about the run.
|
||||||
"""
|
"""
|
||||||
logger.info("Manual deletion trigger requested")
|
logger.info("Manual deletion trigger requested - forcing immediate deletion")
|
||||||
process_deletion_queue()
|
process_deletion_queue(force=True)
|
||||||
|
|
||||||
# Return stats (simplified version)
|
# Return stats (simplified version)
|
||||||
Query_ = Query()
|
Query_ = Query()
|
||||||
|
|||||||
@@ -283,3 +283,207 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
expect(wrapper.vm.deleteErrorMessage).toBe('This account is already marked for deletion.')
|
expect(wrapper.vm.deleteErrorMessage).toBe('This account is already marked for deletion.')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('UserProfile - Profile Update', () => {
|
||||||
|
let wrapper: VueWrapper<any>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
;(global.fetch as any).mockClear()
|
||||||
|
|
||||||
|
// Mock fetch for profile loading in onMounted
|
||||||
|
;(global.fetch as any).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
image_id: 'initial-image-id',
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mount component with router
|
||||||
|
wrapper = mount(UserProfile, {
|
||||||
|
global: {
|
||||||
|
plugins: [mockRouter],
|
||||||
|
stubs: {
|
||||||
|
EntityEditForm: {
|
||||||
|
template: '<div class="mock-form"><slot /></div>',
|
||||||
|
props: ['initialData', 'fields', 'loading', 'error', 'isEdit', 'entityLabel', 'title'],
|
||||||
|
emits: ['submit', 'cancel', 'add-image'],
|
||||||
|
},
|
||||||
|
ModalDialog: {
|
||||||
|
template: '<div class="mock-modal"><slot /></div>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates initialData after successful profile save', async () => {
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Initial image_id should be set from mount
|
||||||
|
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id')
|
||||||
|
|
||||||
|
// Mock successful save response
|
||||||
|
;(global.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simulate form submission with new image_id
|
||||||
|
const newFormData = {
|
||||||
|
image_id: 'new-image-id',
|
||||||
|
first_name: 'Updated',
|
||||||
|
last_name: 'Name',
|
||||||
|
email: 'test@example.com',
|
||||||
|
}
|
||||||
|
|
||||||
|
await wrapper.vm.handleSubmit(newFormData)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// initialData should now be updated to match the saved form
|
||||||
|
expect(wrapper.vm.initialData.image_id).toBe('new-image-id')
|
||||||
|
expect(wrapper.vm.initialData.first_name).toBe('Updated')
|
||||||
|
expect(wrapper.vm.initialData.last_name).toBe('Name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows dirty detection after save when reverting to original value', async () => {
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Start with initial-image-id
|
||||||
|
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id')
|
||||||
|
|
||||||
|
// Mock successful save
|
||||||
|
;(global.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Change and save to new-image-id
|
||||||
|
await wrapper.vm.handleSubmit({
|
||||||
|
image_id: 'new-image-id',
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// initialData should now be new-image-id
|
||||||
|
expect(wrapper.vm.initialData.image_id).toBe('new-image-id')
|
||||||
|
|
||||||
|
// Now if user changes back to initial-image-id, it should be detected as different
|
||||||
|
// (because initialData is now new-image-id)
|
||||||
|
const currentInitial = wrapper.vm.initialData.image_id
|
||||||
|
expect(currentInitial).toBe('new-image-id')
|
||||||
|
expect(currentInitial).not.toBe('initial-image-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles image upload during profile save', async () => {
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' })
|
||||||
|
wrapper.vm.localImageFile = mockFile
|
||||||
|
|
||||||
|
// Mock image upload response
|
||||||
|
;(global.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ id: 'uploaded-image-id' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock profile update response
|
||||||
|
;(global.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.vm.handleSubmit({
|
||||||
|
image_id: 'local-upload',
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Should have called image upload
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/image/upload',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// initialData should be updated with uploaded image ID
|
||||||
|
expect(wrapper.vm.initialData.image_id).toBe('uploaded-image-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error message on failed image upload', async () => {
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' })
|
||||||
|
wrapper.vm.localImageFile = mockFile
|
||||||
|
|
||||||
|
// Mock failed image upload
|
||||||
|
;(global.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.vm.handleSubmit({
|
||||||
|
image_id: 'local-upload',
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.vm.errorMsg).toBe('Failed to upload image.')
|
||||||
|
expect(wrapper.vm.loading).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success modal after profile update', async () => {
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
;(global.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.vm.handleSubmit({
|
||||||
|
image_id: 'some-image-id',
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.vm.showModal).toBe(true)
|
||||||
|
expect(wrapper.vm.modalTitle).toBe('Profile Updated')
|
||||||
|
expect(wrapper.vm.modalMessage).toBe('Your profile was updated successfully.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error message on failed profile update', async () => {
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
;(global.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.vm.handleSubmit({
|
||||||
|
image_id: 'some-image-id',
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.vm.errorMsg).toBe('Failed to update profile.')
|
||||||
|
expect(wrapper.vm.loading).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -165,20 +165,53 @@ function handleRewardModified(event: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerTask = (task: Task) => {
|
const triggerTask = async (task: Task) => {
|
||||||
if ('speechSynthesis' in window && task.name) {
|
// Cancel any pending speech to avoid conflicts
|
||||||
|
if ('speechSynthesis' in window) {
|
||||||
|
window.speechSynthesis.cancel()
|
||||||
|
|
||||||
|
if (task.name) {
|
||||||
const utter = new window.SpeechSynthesisUtterance(task.name)
|
const utter = new window.SpeechSynthesisUtterance(task.name)
|
||||||
|
utter.rate = 1.0
|
||||||
|
utter.pitch = 1.0
|
||||||
|
utter.volume = 1.0
|
||||||
window.speechSynthesis.speak(utter)
|
window.speechSynthesis.speak(utter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger the task via API
|
||||||
|
if (child.value?.id && task.id) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/child/${child.value.id}/trigger-task`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ task_id: task.id }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.error('Failed to trigger task')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error triggering task:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const triggerReward = (reward: RewardStatus) => {
|
const triggerReward = (reward: RewardStatus) => {
|
||||||
if ('speechSynthesis' in window && reward.name) {
|
// Cancel any pending speech to avoid conflicts
|
||||||
|
if ('speechSynthesis' in window) {
|
||||||
|
window.speechSynthesis.cancel()
|
||||||
|
|
||||||
|
if (reward.name) {
|
||||||
const utterString =
|
const utterString =
|
||||||
reward.name +
|
reward.name +
|
||||||
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
|
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
|
||||||
const utter = new window.SpeechSynthesisUtterance(utterString)
|
const utter = new window.SpeechSynthesisUtterance(utterString)
|
||||||
|
utter.rate = 1.0
|
||||||
|
utter.pitch = 1.0
|
||||||
|
utter.volume = 1.0
|
||||||
window.speechSynthesis.speak(utter)
|
window.speechSynthesis.speak(utter)
|
||||||
|
}
|
||||||
|
|
||||||
if (reward.redeeming) {
|
if (reward.redeeming) {
|
||||||
dialogReward.value = reward
|
dialogReward.value = reward
|
||||||
showCancelDialog.value = true
|
showCancelDialog.value = true
|
||||||
@@ -271,6 +304,12 @@ function removeInactivityListeners() {
|
|||||||
if (inactivityTimer) clearTimeout(inactivityTimer)
|
if (inactivityTimer) clearTimeout(inactivityTimer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const readyItemId = ref<string | null>(null)
|
||||||
|
|
||||||
|
function handleItemReady(itemId: string) {
|
||||||
|
readyItemId.value = itemId
|
||||||
|
}
|
||||||
|
|
||||||
const hasPendingRewards = computed(() =>
|
const hasPendingRewards = computed(() =>
|
||||||
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
|
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
|
||||||
)
|
)
|
||||||
@@ -333,6 +372,9 @@ onUnmounted(() => {
|
|||||||
:ids="tasks"
|
:ids="tasks"
|
||||||
itemKey="tasks"
|
itemKey="tasks"
|
||||||
imageField="image_id"
|
imageField="image_id"
|
||||||
|
:isParentAuthenticated="false"
|
||||||
|
:readyItemId="readyItemId"
|
||||||
|
@item-ready="handleItemReady"
|
||||||
@trigger-item="triggerTask"
|
@trigger-item="triggerTask"
|
||||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||||
:filter-fn="
|
:filter-fn="
|
||||||
@@ -364,6 +406,9 @@ onUnmounted(() => {
|
|||||||
:ids="tasks"
|
:ids="tasks"
|
||||||
itemKey="tasks"
|
itemKey="tasks"
|
||||||
imageField="image_id"
|
imageField="image_id"
|
||||||
|
:isParentAuthenticated="false"
|
||||||
|
:readyItemId="readyItemId"
|
||||||
|
@item-ready="handleItemReady"
|
||||||
@trigger-item="triggerTask"
|
@trigger-item="triggerTask"
|
||||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||||
:filter-fn="
|
:filter-fn="
|
||||||
@@ -394,6 +439,9 @@ onUnmounted(() => {
|
|||||||
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
|
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
|
||||||
itemKey="reward_status"
|
itemKey="reward_status"
|
||||||
imageField="image_id"
|
imageField="image_id"
|
||||||
|
:isParentAuthenticated="false"
|
||||||
|
:readyItemId="readyItemId"
|
||||||
|
@item-ready="handleItemReady"
|
||||||
@trigger-item="triggerReward"
|
@trigger-item="triggerReward"
|
||||||
:getItemClass="
|
:getItemClass="
|
||||||
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })
|
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ describe('ChildView', () => {
|
|||||||
// Mock speech synthesis
|
// Mock speech synthesis
|
||||||
global.window.speechSynthesis = {
|
global.window.speechSynthesis = {
|
||||||
speak: vi.fn(),
|
speak: vi.fn(),
|
||||||
|
cancel: vi.fn(),
|
||||||
} as any
|
} as any
|
||||||
global.window.SpeechSynthesisUtterance = vi.fn() as any
|
global.window.SpeechSynthesisUtterance = vi.fn() as any
|
||||||
})
|
})
|
||||||
@@ -186,13 +187,18 @@ describe('ChildView', () => {
|
|||||||
it('speaks task name when triggered', () => {
|
it('speaks task name when triggered', () => {
|
||||||
wrapper.vm.triggerTask(mockChore)
|
wrapper.vm.triggerTask(mockChore)
|
||||||
|
|
||||||
|
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
|
||||||
expect(window.speechSynthesis.speak).toHaveBeenCalled()
|
expect(window.speechSynthesis.speak).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not crash if speechSynthesis is not available', () => {
|
it('does not crash if speechSynthesis is not available', () => {
|
||||||
|
const originalSpeechSynthesis = global.window.speechSynthesis
|
||||||
delete (global.window as any).speechSynthesis
|
delete (global.window as any).speechSynthesis
|
||||||
|
|
||||||
expect(() => wrapper.vm.triggerTask(mockChore)).not.toThrow()
|
expect(() => wrapper.vm.triggerTask(mockChore)).not.toThrow()
|
||||||
|
|
||||||
|
// Restore for other tests
|
||||||
|
global.window.speechSynthesis = originalSpeechSynthesis
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -309,4 +315,95 @@ describe('ChildView', () => {
|
|||||||
expect(mockRefresh).not.toHaveBeenCalled()
|
expect(mockRefresh).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Item Ready State Management', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
wrapper = mount(ChildView)
|
||||||
|
await nextTick()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initializes readyItemId to null', () => {
|
||||||
|
expect(wrapper.vm.readyItemId).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates readyItemId when handleItemReady is called with an item ID', () => {
|
||||||
|
wrapper.vm.handleItemReady('task-1')
|
||||||
|
expect(wrapper.vm.readyItemId).toBe('task-1')
|
||||||
|
|
||||||
|
wrapper.vm.handleItemReady('reward-2')
|
||||||
|
expect(wrapper.vm.readyItemId).toBe('reward-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears readyItemId when handleItemReady is called with empty string', () => {
|
||||||
|
wrapper.vm.readyItemId = 'task-1'
|
||||||
|
wrapper.vm.handleItemReady('')
|
||||||
|
expect(wrapper.vm.readyItemId).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes readyItemId prop to Chores ScrollingList', async () => {
|
||||||
|
wrapper.vm.readyItemId = 'task-1'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const choresScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[0]
|
||||||
|
expect(choresScrollingList.props('readyItemId')).toBe('task-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes readyItemId prop to Penalties ScrollingList', async () => {
|
||||||
|
wrapper.vm.readyItemId = 'task-2'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const penaltiesScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[1]
|
||||||
|
expect(penaltiesScrollingList.props('readyItemId')).toBe('task-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes readyItemId prop to Rewards ScrollingList', async () => {
|
||||||
|
wrapper.vm.readyItemId = 'reward-1'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const rewardsScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[2]
|
||||||
|
expect(rewardsScrollingList.props('readyItemId')).toBe('reward-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles item-ready event from Chores ScrollingList', async () => {
|
||||||
|
const choresScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[0]
|
||||||
|
|
||||||
|
choresScrollingList.vm.$emit('item-ready', 'task-1')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.vm.readyItemId).toBe('task-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles item-ready event from Penalties ScrollingList', async () => {
|
||||||
|
const penaltiesScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[1]
|
||||||
|
|
||||||
|
penaltiesScrollingList.vm.$emit('item-ready', 'task-2')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.vm.readyItemId).toBe('task-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles item-ready event from Rewards ScrollingList', async () => {
|
||||||
|
const rewardsScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[2]
|
||||||
|
|
||||||
|
rewardsScrollingList.vm.$emit('item-ready', 'reward-1')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.vm.readyItemId).toBe('reward-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maintains 2-step click workflow: first click sets ready, second click triggers', async () => {
|
||||||
|
// Initial state
|
||||||
|
expect(wrapper.vm.readyItemId).toBe(null)
|
||||||
|
|
||||||
|
// First click - item should become ready
|
||||||
|
wrapper.vm.handleItemReady('task-1')
|
||||||
|
expect(wrapper.vm.readyItemId).toBe('task-1')
|
||||||
|
|
||||||
|
// Second click would trigger the item (tested via ScrollingList component)
|
||||||
|
// After trigger, ready state should be cleared
|
||||||
|
wrapper.vm.handleItemReady('')
|
||||||
|
expect(wrapper.vm.readyItemId).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ async function updateProfile(form: {
|
|||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) throw new Error('Failed to update profile')
|
if (!res.ok) throw new Error('Failed to update profile')
|
||||||
|
// Update initialData to reflect the saved state
|
||||||
|
initialData.value = { ...form }
|
||||||
modalTitle.value = 'Profile Updated'
|
modalTitle.value = 'Profile Updated'
|
||||||
modalSubtitle.value = ''
|
modalSubtitle.value = ''
|
||||||
modalMessage.value = 'Your profile was updated successfully.'
|
modalMessage.value = 'Your profile was updated successfully.'
|
||||||
@@ -245,7 +247,11 @@ async function updateProfile(form: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handlePasswordModalClose() {
|
async function handlePasswordModalClose() {
|
||||||
|
const wasProfileUpdate = modalTitle.value === 'Profile Updated'
|
||||||
showModal.value = false
|
showModal.value = false
|
||||||
|
if (wasProfileUpdate) {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetPassword() {
|
async function resetPassword() {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const fields: {
|
|||||||
}[] = [
|
}[] = [
|
||||||
{ name: 'name', label: 'Reward Name', type: 'text', required: true, maxlength: 64 },
|
{ name: 'name', label: 'Reward Name', type: 'text', required: true, maxlength: 64 },
|
||||||
{ name: 'description', label: 'Description', type: 'text', maxlength: 128 },
|
{ name: 'description', label: 'Description', type: 'text', maxlength: 128 },
|
||||||
{ name: 'cost', label: 'Cost', type: 'number', required: true, min: 1, max: 1000 },
|
{ name: 'cost', label: 'Cost', type: 'number', required: true, min: 1, max: 10000 },
|
||||||
{ name: 'image_id', label: 'Image', type: 'image', imageType: 2 },
|
{ name: 'image_id', label: 'Image', type: 'image', imageType: 2 },
|
||||||
]
|
]
|
||||||
// removed duplicate defineProps
|
// removed duplicate defineProps
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
|
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="loading || !isDirty">
|
<button type="submit" class="btn btn-primary" :disabled="loading || !isDirty || !isValid">
|
||||||
{{ isEdit ? 'Save' : 'Create' }}
|
{{ isEdit ? 'Save' : 'Create' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, nextTick, watch } from 'vue'
|
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import '@/assets/styles.css'
|
import '@/assets/styles.css'
|
||||||
@@ -120,9 +120,33 @@ function checkDirty() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validation logic
|
||||||
|
const isValid = computed(() => {
|
||||||
|
return props.fields.every((field) => {
|
||||||
|
if (!field.required) return true
|
||||||
|
const value = formData.value[field.name]
|
||||||
|
|
||||||
|
if (field.type === 'text') {
|
||||||
|
return typeof value === 'string' && value.trim().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'number') {
|
||||||
|
const numValue = Number(value)
|
||||||
|
if (isNaN(numValue)) return false
|
||||||
|
if (field.min !== undefined && numValue < field.min) return false
|
||||||
|
if (field.max !== undefined && numValue > field.max) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other types, just check it's not null/undefined
|
||||||
|
return value != null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => ({ ...formData.value }),
|
() => ({ ...formData.value }),
|
||||||
() => {
|
(newVal) => {
|
||||||
|
console.log('formData changed:', newVal)
|
||||||
checkDirty()
|
checkDirty()
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const fields: {
|
|||||||
imageType?: number
|
imageType?: number
|
||||||
}[] = [
|
}[] = [
|
||||||
{ name: 'name', label: 'Task Name', type: 'text', required: true, maxlength: 64 },
|
{ name: 'name', label: 'Task Name', type: 'text', required: true, maxlength: 64 },
|
||||||
{ name: 'points', label: 'Task Points', type: 'number', required: true, min: 1, max: 100 },
|
{ name: 'points', label: 'Task Points', type: 'number', required: true, min: 1, max: 1000 },
|
||||||
{ name: 'is_good', label: 'Task Type', type: 'custom' },
|
{ name: 'is_good', label: 'Task Type', type: 'custom' },
|
||||||
{ name: 'image_id', label: 'Image', type: 'image', imageType: 2 },
|
{ name: 'image_id', label: 'Image', type: 'image', imageType: 2 },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user