feat: enhance child edit and view components with improved form handling and validation
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m4s
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m4s
- Added `requireDirty` prop to `EntityEditForm` for dirty state management. - Updated `ChildEditView` to handle initial data loading and image selection more robustly. - Refactored `ChildView` to remove unused reward dialog logic and prevent API calls in child mode. - Improved type definitions for form fields and initial data in `ChildEditView`. - Enhanced error handling in form submissions across components. - Implemented cross-tab logout synchronization on password reset in the auth store. - Added tests for login and entity edit form functionalities to ensure proper behavior. - Introduced global fetch interceptor for handling unauthorized responses. - Documented password reset flow and its implications on session management.
This commit is contained in:
@@ -162,6 +162,7 @@ def login():
|
||||
payload = {
|
||||
'email': norm_email,
|
||||
'user_id': user.id,
|
||||
'token_version': user.token_version,
|
||||
'exp': datetime.utcnow() + timedelta(hours=24*7)
|
||||
}
|
||||
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
|
||||
@@ -179,10 +180,13 @@ def me():
|
||||
try:
|
||||
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||
user_id = payload.get('user_id', '')
|
||||
token_version = payload.get('token_version', 0)
|
||||
user_dict = users_db.get(UserQuery.id == user_id)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
||||
if token_version != user.token_version:
|
||||
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 401
|
||||
if user.marked_for_deletion:
|
||||
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
|
||||
return jsonify({
|
||||
@@ -268,9 +272,12 @@ def reset_password():
|
||||
user.password = generate_password_hash(new_password)
|
||||
user.reset_token = None
|
||||
user.reset_token_created = None
|
||||
user.token_version += 1
|
||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||
|
||||
return jsonify({'message': 'Password has been reset'}), 200
|
||||
resp = jsonify({'message': 'Password has been reset'})
|
||||
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict')
|
||||
return resp, 200
|
||||
|
||||
@auth_api.route('/logout', methods=['POST'])
|
||||
def logout():
|
||||
|
||||
@@ -64,10 +64,24 @@ def list_tasks():
|
||||
continue # Skip default if user version exists
|
||||
filtered_tasks.append(t)
|
||||
|
||||
# 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
|
||||
# Sort order:
|
||||
# 1) good tasks first, then not-good tasks
|
||||
# 2) within each group: user-created items first (by name), then default items (by name)
|
||||
good_tasks = [t for t in filtered_tasks if t.get('is_good') is True]
|
||||
not_good_tasks = [t for t in filtered_tasks if t.get('is_good') is not True]
|
||||
|
||||
def sort_user_then_default(tasks_group):
|
||||
user_created = sorted(
|
||||
[t for t in tasks_group if t.get('user_id') == user_id],
|
||||
key=lambda x: x['name'].lower(),
|
||||
)
|
||||
default_items = sorted(
|
||||
[t for t in tasks_group if t.get('user_id') is None],
|
||||
key=lambda x: x['name'].lower(),
|
||||
)
|
||||
return user_created + default_items
|
||||
|
||||
sorted_tasks = sort_user_then_default(good_tasks) + sort_user_then_default(not_good_tasks)
|
||||
|
||||
return jsonify({'tasks': sorted_tasks}), 200
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ def get_current_user_id():
|
||||
user_id = payload.get('user_id')
|
||||
if not user_id:
|
||||
return None
|
||||
token_version = payload.get('token_version', 0)
|
||||
user = users_db.get(Query().id == user_id)
|
||||
if not user:
|
||||
return None
|
||||
if token_version != user.get('token_version', 0):
|
||||
return None
|
||||
return user_id
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# file: config/version.py
|
||||
import os
|
||||
|
||||
BASE_VERSION = "1.0.4RC2" # update manually when releasing features
|
||||
BASE_VERSION = "1.0.4RC3" # update manually when releasing features
|
||||
|
||||
def get_full_version() -> str:
|
||||
"""
|
||||
|
||||
@@ -21,6 +21,7 @@ class User(BaseModel):
|
||||
deletion_in_progress: bool = False
|
||||
deletion_attempted_at: str | None = None
|
||||
role: str = 'user'
|
||||
token_version: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
@@ -43,6 +44,7 @@ class User(BaseModel):
|
||||
deletion_in_progress=d.get('deletion_in_progress', False),
|
||||
deletion_attempted_at=d.get('deletion_attempted_at'),
|
||||
role=d.get('role', 'user'),
|
||||
token_version=d.get('token_version', 0),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at')
|
||||
@@ -69,6 +71,7 @@ class User(BaseModel):
|
||||
'marked_for_deletion_at': self.marked_for_deletion_at,
|
||||
'deletion_in_progress': self.deletion_in_progress,
|
||||
'deletion_attempted_at': self.deletion_attempted_at,
|
||||
'role': self.role
|
||||
'role': self.role,
|
||||
'token_version': self.token_version,
|
||||
})
|
||||
return base
|
||||
|
||||
@@ -100,6 +100,38 @@ def test_reset_password_hashes_new_password(client):
|
||||
assert user_dict['password'].startswith('scrypt:')
|
||||
assert check_password_hash(user_dict['password'], 'newpassword123')
|
||||
|
||||
|
||||
def test_reset_password_invalidates_existing_jwt(client):
|
||||
users_db.remove(Query().email == 'test@example.com')
|
||||
user = User(
|
||||
first_name='Test',
|
||||
last_name='User',
|
||||
email='test@example.com',
|
||||
password=generate_password_hash('oldpassword123'),
|
||||
verified=True,
|
||||
reset_token='validtoken2',
|
||||
reset_token_created=datetime.utcnow().isoformat(),
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
|
||||
login_response = client.post('/auth/login', json={'email': 'test@example.com', 'password': 'oldpassword123'})
|
||||
assert login_response.status_code == 200
|
||||
login_cookie = login_response.headers.get('Set-Cookie', '')
|
||||
assert 'token=' in login_cookie
|
||||
old_token = login_cookie.split('token=', 1)[1].split(';', 1)[0]
|
||||
assert old_token
|
||||
|
||||
reset_response = client.post('/auth/reset-password', json={'token': 'validtoken2', 'password': 'newpassword123'})
|
||||
assert reset_response.status_code == 200
|
||||
reset_cookie = reset_response.headers.get('Set-Cookie', '')
|
||||
assert 'token=' in reset_cookie
|
||||
|
||||
# Set the old token as a cookie and test that it's now invalid
|
||||
client.set_cookie('token', old_token)
|
||||
me_response = client.get('/auth/me')
|
||||
assert me_response.status_code == 401
|
||||
assert me_response.json['code'] == 'INVALID_TOKEN'
|
||||
|
||||
def test_migration_script_hashes_plain_text_passwords():
|
||||
"""Test the migration script hashes plain text passwords."""
|
||||
# Clean up
|
||||
|
||||
@@ -80,6 +80,36 @@ def test_list_tasks(client):
|
||||
assert len(data['tasks']) == 2
|
||||
|
||||
|
||||
def test_list_tasks_sorted_by_is_good_then_user_then_default_then_name(client):
|
||||
task_db.truncate()
|
||||
|
||||
task_db.insert({'id': 'u_good_z', 'name': 'Zoo', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
|
||||
task_db.insert({'id': 'u_good_a', 'name': 'Apple', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
|
||||
task_db.insert({'id': 'd_good_m', 'name': 'Mop', 'points': 1, 'is_good': True, 'user_id': None})
|
||||
task_db.insert({'id': 'd_good_b', 'name': 'Brush', 'points': 1, 'is_good': True, 'user_id': None})
|
||||
|
||||
task_db.insert({'id': 'u_bad_c', 'name': 'Chore', 'points': 1, 'is_good': False, 'user_id': 'testuserid'})
|
||||
task_db.insert({'id': 'u_bad_a', 'name': 'Alarm', 'points': 1, 'is_good': False, 'user_id': 'testuserid'})
|
||||
task_db.insert({'id': 'd_bad_y', 'name': 'Yell', 'points': 1, 'is_good': False, 'user_id': None})
|
||||
task_db.insert({'id': 'd_bad_b', 'name': 'Bicker', 'points': 1, 'is_good': False, 'user_id': None})
|
||||
|
||||
response = client.get('/task/list')
|
||||
assert response.status_code == 200
|
||||
|
||||
tasks = response.json['tasks']
|
||||
ordered_ids = [t['id'] for t in tasks]
|
||||
assert ordered_ids == [
|
||||
'u_good_a',
|
||||
'u_good_z',
|
||||
'd_good_b',
|
||||
'd_good_m',
|
||||
'u_bad_a',
|
||||
'u_bad_c',
|
||||
'd_bad_b',
|
||||
'd_bad_y',
|
||||
]
|
||||
|
||||
|
||||
def test_get_task_not_found(client):
|
||||
response = client.get('/task/nonexistent-id')
|
||||
assert response.status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user