From 6f5b61de7fb75e5bbdd4ae46d6b94cda5731249f Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Wed, 28 Jan 2026 16:42:06 -0500 Subject: [PATCH] feat: normalize email handling in signup, login, and verification processes; refactor event handling in task and reward components --- backend/api/auth_api.py | 35 +++++--- backend/api/child_api.py | 4 +- backend/api/reward_api.py | 23 ++--- backend/api/task_api.py | 33 ++++--- backend/api/utils.py | 11 +++ .../src/components/child/ChildView.vue | 86 +------------------ .../src/components/child/ParentView.vue | 11 ++- .../src/components/child/TaskAssignView.vue | 1 - .../src/components/profile/UserProfile.vue | 13 +-- .../src/components/reward/RewardView.vue | 30 +++++-- .../components/shared/ChildrenListView.vue | 3 - .../src/components/shared/DeleteModal.vue | 17 +++- .../src/components/shared/EntityEditForm.vue | 3 +- .../src/components/shared/ItemList.vue | 8 +- .../src/components/shared/LoginButton.vue | 33 ++++++- .../vue-app/src/components/task/TaskView.vue | 34 ++++++-- frontend/vue-app/src/layout/ParentLayout.vue | 2 +- frontend/vue-app/src/router/index.ts | 13 ++- 18 files changed, 188 insertions(+), 172 deletions(-) diff --git a/backend/api/auth_api.py b/backend/api/auth_api.py index 1257ae3..0ce436d 100644 --- a/backend/api/auth_api.py +++ b/backend/api/auth_api.py @@ -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 diff --git a/backend/api/child_api.py b/backend/api/child_api.py index c898f68..6ee1adf 100644 --- a/backend/api/child_api.py +++ b/backend/api/child_api.py @@ -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, diff --git a/backend/api/reward_api.py b/backend/api/reward_api.py index 259b41e..7d2064a 100644 --- a/backend/api/reward_api.py +++ b/backend/api/reward_api.py @@ -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/', 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 diff --git a/backend/api/task_api.py b/backend/api/task_api.py index d6434cf..a356ff7 100644 --- a/backend/api/task_api.py +++ b/backend/api/task_api.py @@ -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/', 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/', 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 diff --git a/backend/api/utils.py b/backend/api/utils.py index dfe9c95..87ab364 100644 --- a/backend/api/utils.py +++ b/backend/api/utils.py @@ -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_') diff --git a/frontend/vue-app/src/components/child/ChildView.vue b/frontend/vue-app/src/components/child/ChildView.vue index 6119bf1..bfc3977 100644 --- a/frontend/vue-app/src/components/child/ChildView.vue +++ b/frontend/vue-app/src/components/child/ChildView.vue @@ -4,6 +4,7 @@ import ModalDialog from '../shared/ModalDialog.vue' import { useRoute, useRouter } from 'vue-router' import ChildDetailCard from './ChildDetailCard.vue' import ScrollingList from '../shared/ScrollingList.vue' +import StatusMessage from '../shared/StatusMessage.vue' import { eventBus } from '@/common/eventBus' import '@/assets/view-shared.css' import '@/assets/styles.css' @@ -176,7 +177,6 @@ const triggerReward = (reward: RewardStatus) => { (reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`) const utter = new window.SpeechSynthesisUtterance(utterString) window.speechSynthesis.speak(utter) - console.log('Reward data is:', reward) if (reward.redeeming) { dialogReward.value = reward showCancelDialog.value = true @@ -405,90 +405,6 @@ onUnmounted(() => { -
-
- - - - - - - - - - -
-
- { selectedTask.value = task const pendingRewardIds = getPendingRewardIds() - console.log('Pending reward IDs:', pendingRewardIds) if (pendingRewardIds.length > 0) { showPendingRewardDialog.value = true return @@ -263,7 +263,7 @@ async function cancelPendingReward() { try { const pendingRewardIds = getPendingRewardIds() await Promise.all(pendingRewardIds.map((id: string) => cancelRewardById(id))) - childRewardListRef.value?.refresh() + //childRewardListRef.value?.refresh() } catch (err) { console.error('Failed to cancel pending reward:', err) } finally { @@ -296,6 +296,12 @@ const confirmTriggerTask = async () => { const triggerReward = (reward: RewardStatus) => { if (reward.points_needed > 0) return + // If there is a pending reward and it's not this one, show the pending dialog + const pendingRewardIds = getPendingRewardIds() + if (pendingRewardIds.length > 0 && !reward.redeeming) { + showPendingRewardDialog.value = true + return + } selectedReward.value = reward showRewardConfirm.value = true } @@ -403,6 +409,7 @@ function goToAssignRewards() { :fetchBaseUrl="`/api/child/${child?.id}/reward-status`" itemKey="reward_status" imageField="image_id" + :ids="rewards" @trigger-item="triggerReward" :getItemClass="(item) => ({ reward: true })" > diff --git a/frontend/vue-app/src/components/child/TaskAssignView.vue b/frontend/vue-app/src/components/child/TaskAssignView.vue index a2a64cd..4826a0a 100644 --- a/frontend/vue-app/src/components/child/TaskAssignView.vue +++ b/frontend/vue-app/src/components/child/TaskAssignView.vue @@ -58,7 +58,6 @@ function goToCreateTask() { async function onSubmit() { const selectedIds = taskListRef.value?.selectedItems ?? [] try { - console.log('selectedIds:', selectedIds) const resp = await fetch(`/api/child/${childId}/set-tasks`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, diff --git a/frontend/vue-app/src/components/profile/UserProfile.vue b/frontend/vue-app/src/components/profile/UserProfile.vue index 74de895..3b852d6 100644 --- a/frontend/vue-app/src/components/profile/UserProfile.vue +++ b/frontend/vue-app/src/components/profile/UserProfile.vue @@ -9,6 +9,7 @@ :error="errorMsg" :title="'User Profile'" @submit="handleSubmit" + @cancel="router.back" @add-image="onAddImage" >