feat: normalize email handling in signup, login, and verification processes; refactor event handling in task and reward components
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 50s

This commit is contained in:
2026-01-28 16:42:06 -05:00
parent 3066d7d356
commit 6f5b61de7f
18 changed files with 188 additions and 172 deletions

View File

@@ -14,6 +14,7 @@ from api.error_codes import MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD, INVALID_CREDENTIALS, \
NOT_VERIFIED
from db.db import users_db
from api.utils import normalize_email
logger = logging.getLogger(__name__)
auth_api = Blueprint('auth_api', __name__)
@@ -34,8 +35,10 @@ def signup():
required_fields = ['first_name', 'last_name', 'email', 'password']
if not all(field in data for field in required_fields):
return jsonify({'error': 'Missing required fields', 'code': MISSING_FIELDS}), 400
email = data.get('email', '')
norm_email = normalize_email(email)
if users_db.search(UserQuery.email == data['email']):
if users_db.search(UserQuery.email == norm_email):
return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400
token = secrets.token_urlsafe(32)
@@ -43,7 +46,7 @@ def signup():
user = User(
first_name=data['first_name'],
last_name=data['last_name'],
email=data['email'],
email=norm_email,
password=data['password'], # Hash in production!
verified=False,
verify_token=token,
@@ -51,7 +54,7 @@ def signup():
image_id="boy01"
)
users_db.insert(user.to_dict())
send_verification_email(data['email'], token)
send_verification_email(norm_email, token)
return jsonify({'message': 'User created, verification email sent'}), 201
@auth_api.route('/verify', methods=['GET'])
@@ -105,11 +108,12 @@ def verify():
@auth_api.route('/resend-verify', methods=['POST'])
def resend_verify():
data = request.get_json()
email = data.get('email')
email = data.get('email', '')
if not email:
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
norm_email = normalize_email(email)
user_dict = users_db.get(UserQuery.email == email)
user_dict = users_db.get(UserQuery.email == norm_email)
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
@@ -121,19 +125,20 @@ def resend_verify():
now_iso = datetime.utcnow().isoformat()
user.verify_token = token
user.verify_token_created = now_iso
users_db.update(user.to_dict(), UserQuery.email == email)
send_verification_email(email, token)
users_db.update(user.to_dict(), UserQuery.email == norm_email)
send_verification_email(norm_email, token)
return jsonify({'message': 'Verification email resent'}), 200
@auth_api.route('/login', methods=['POST'])
def login():
data = request.get_json()
email = data.get('email')
email = data.get('email', '')
password = data.get('password')
if not email or not password:
return jsonify({'error': 'Missing email or password', 'code': MISSING_EMAIL_OR_PASSWORD}), 400
norm_email = normalize_email(email)
user_dict = users_db.get(UserQuery.email == email)
user_dict = users_db.get(UserQuery.email == norm_email)
user = User.from_dict(user_dict) if user_dict else None
if not user or user.password != password:
return jsonify({'error': 'Invalid credentials', 'code': INVALID_CREDENTIALS}), 401
@@ -142,7 +147,7 @@ def login():
return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403
payload = {
'email': email,
'email': norm_email,
'exp': datetime.utcnow() + timedelta(hours=24*7)
}
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
@@ -179,21 +184,23 @@ def me():
@auth_api.route('/request-password-reset', methods=['POST'])
def request_password_reset():
data = request.get_json()
email = data.get('email')
email = data.get('email', '')
norm_email = normalize_email(email)
success_msg = 'If this email is registered, you will receive a password reset link shortly.'
if not email:
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
user_dict = users_db.get(UserQuery.email == email)
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 == email)
send_reset_password_email(email, token)
users_db.update(user.to_dict(), UserQuery.email == norm_email)
send_reset_password_email(norm_email, token)
return jsonify({'message': success_msg}), 200

View File

@@ -401,9 +401,7 @@ def set_child_rewards(id):
# Replace rewards with validated IDs
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
resp = send_event_for_current_user(Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, valid_reward_ids)))
if resp:
return resp
send_event_for_current_user(Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, valid_reward_ids)))
return jsonify({
'message': f'Rewards set for child {id}.',
'reward_ids': valid_reward_ids,

View File

@@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user
from backend.events.types.child_rewards_set import ChildRewardsSet
from db.db import reward_db, child_db
from events.types.event import Event
from events.types.event_types import EventType
@@ -22,10 +23,7 @@ def add_reward():
return jsonify({'error': 'Name, description, and cost are required'}), 400
reward = Reward(name=name, description=description, cost=cost, image_id=image)
reward_db.insert(reward.to_dict())
resp = send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
RewardModified(reward.id, RewardModified.OPERATION_ADD)))
if resp:
return resp
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value, RewardModified(reward.id, RewardModified.OPERATION_ADD)))
return jsonify({'message': f'Reward {name} added.'}), 201
@@ -40,7 +38,14 @@ def get_reward(id):
@reward_api.route('/reward/list', methods=['GET'])
def list_rewards():
ids_param = request.args.get('ids')
rewards = reward_db.all()
if ids_param is not None:
if ids_param.strip() == '':
rewards = []
else:
ids = set(ids_param.split(','))
rewards = [reward for reward in rewards if reward.get('id') in ids]
return jsonify({'rewards': rewards}), 200
@reward_api.route('/reward/<id>', methods=['DELETE'])
@@ -55,10 +60,8 @@ def delete_reward(id):
if id in rewards:
rewards.remove(id)
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
resp = send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
RewardModified(id, RewardModified.OPERATION_DELETE)))
if resp:
return resp
send_event_for_current_user(Event(EventType.CHILD_REWARD_SET.value, ChildRewardsSet(id, rewards)))
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value, RewardModified(id, RewardModified.OPERATION_DELETE)))
return jsonify({'message': f'Reward {id} deleted.'}), 200
return jsonify({'error': 'Reward not found'}), 404
@@ -100,9 +103,7 @@ def edit_reward(id):
reward_db.update(updates, RewardQuery.id == id)
updated = reward_db.get(RewardQuery.id == id)
resp = send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
RewardModified(id, RewardModified.OPERATION_EDIT)))
if resp:
return resp
return jsonify(updated), 200

View File

@@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user
from backend.events.types.child_tasks_set import ChildTasksSet
from db.db import task_db, child_db
from events.types.event import Event
from events.types.event_types import EventType
@@ -22,10 +23,8 @@ def add_task():
return jsonify({'error': 'Name, points, and is_good are required'}), 400
task = Task(name=name, points=points, is_good=is_good, image_id=image)
task_db.insert(task.to_dict())
resp = send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
if resp:
return resp
return jsonify({'message': f'Task {name} added.'}), 201
@task_api.route('/task/<id>', methods=['GET'])
@@ -40,9 +39,12 @@ def get_task(id):
def list_tasks():
ids_param = request.args.get('ids')
tasks = task_db.all()
if ids_param:
ids = set(ids_param.split(','))
tasks = [task for task in tasks if task.get('id') in ids]
if ids_param is not None:
if ids_param.strip() == '':
tasks = []
else:
ids = set(ids_param.split(','))
tasks = [task for task in tasks if task.get('id') in ids]
return jsonify({'tasks': tasks}), 200
@task_api.route('/task/<id>', methods=['DELETE'])
@@ -53,15 +55,12 @@ def delete_task(id):
# remove the task id from any child's task list
ChildQuery = Query()
for child in child_db.all():
tasks = child.get('tasks', [])
if id in tasks:
tasks.remove(id)
child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id'))
resp = send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_DELETE)))
if resp:
return resp
child_tasks = child.get('tasks', [])
if id in child_tasks:
child_tasks.remove(id)
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
return jsonify({'message': f'Task {id} deleted.'}), 200
return jsonify({'error': 'Task not found'}), 404
@@ -103,8 +102,6 @@ def edit_task(id):
task_db.update(updates, TaskQuery.id == id)
updated = task_db.get(TaskQuery.id == id)
resp = send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_EDIT)))
if resp:
return resp
return jsonify(updated), 200

View File

@@ -1,9 +1,20 @@
import jwt
import re
from flask import request, current_app, jsonify
from events.sse import send_event_to_user
def normalize_email(email: str) -> str:
"""Normalize email for uniqueness checks (Gmail: remove dots and +aliases)."""
email = email.strip().lower()
if '@' not in email:
return email
local, domain = email.split('@', 1)
if domain in ('gmail.com', 'googlemail.com'):
local = local.split('+', 1)[0].replace('.', '')
return f"{local}@{domain}"
def sanitize_email(email):
return email.replace('@', '_at_').replace('.', '_dot_')