From 3066d7d356f20d20b5188912eca9f24026ce6074 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Tue, 27 Jan 2026 14:47:49 -0500 Subject: [PATCH] feat: add parent PIN setup functionality and email notifications - Implemented User model updates to include PIN and related fields. - Created email sender utility for sending verification and reset emails. - Developed ParentPinSetup component for setting up a parent PIN with verification code. - Enhanced UserProfile and EntityEditForm components to support new features. - Updated routing to include PIN setup and authentication checks. - Added styles for new components and improved existing styles for consistency. - Introduced loading states and error handling in various components. --- backend/api/auth_api.py | 23 +- backend/api/user_api.py | 103 ++++++++ backend/main.py | 8 +- backend/models/user.py | 11 +- backend/utils/email_instance.py | 3 + backend/utils/email_sender.py | 65 +++++ frontend/vue-app/src/assets/edit-forms.css | 10 - frontend/vue-app/src/assets/styles.css | 21 ++ .../src/components/auth/ParentPinSetup.vue | 236 ++++++++++++++++++ .../src/components/child/ChildEditView.vue | 26 +- .../src/components/profile/UserProfile.vue | 236 ++++++++++++------ .../src/components/reward/RewardEditView.vue | 25 +- .../components/shared/ChildrenListView.vue | 3 + .../src/components/shared/EntityEditForm.vue | 128 ++++++---- .../src/components/shared/LoginButton.vue | 91 ++++--- .../src/components/task/TaskEditView.vue | 63 ++--- frontend/vue-app/src/layout/ParentLayout.vue | 5 +- frontend/vue-app/src/router/index.ts | 43 ++-- frontend/vue-app/src/stores/auth.ts | 9 +- 19 files changed, 852 insertions(+), 257 deletions(-) create mode 100644 backend/utils/email_instance.py create mode 100644 backend/utils/email_sender.py create mode 100644 frontend/vue-app/src/components/auth/ParentPinSetup.vue diff --git a/backend/api/auth_api.py b/backend/api/auth_api.py index 3b93832..1257ae3 100644 --- a/backend/api/auth_api.py +++ b/backend/api/auth_api.py @@ -3,7 +3,7 @@ import secrets, jwt from datetime import datetime, timedelta, timezone from models.user import User from flask import Blueprint, request, jsonify, current_app -from flask_mail import Mail, Message +from backend.utils.email_instance import email_sender from tinydb import Query import os @@ -18,32 +18,15 @@ from db.db import users_db logger = logging.getLogger(__name__) auth_api = Blueprint('auth_api', __name__) UserQuery = Query() -mail = Mail() TOKEN_EXPIRY_MINUTES = 60*4 RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10 def send_verification_email(to_email, token): - verify_url = f"{current_app.config['FRONTEND_URL']}/auth/verify?token={token}" - html_body = f'Click here to verify your account.' - msg = Message( - subject="Verify your account", - recipients=[to_email], - html=html_body, - sender=current_app.config['MAIL_DEFAULT_SENDER'] - ) - mail.send(msg) + email_sender.send_verification_email(to_email, token) def send_reset_password_email(to_email, token): - reset_url = f"{current_app.config['FRONTEND_URL']}/auth/reset-password?token={token}" - html_body = f'Click here to reset your password.' - msg = Message( - subject="Reset your password", - recipients=[to_email], - html=html_body, - sender=current_app.config['MAIL_DEFAULT_SENDER'] - ) - mail.send(msg) + email_sender.send_reset_password_email(to_email, token) @auth_api.route('/signup', methods=['POST']) def signup(): diff --git a/backend/api/user_api.py b/backend/api/user_api.py index 9e464df..3cc6683 100644 --- a/backend/api/user_api.py +++ b/backend/api/user_api.py @@ -3,6 +3,11 @@ from models.user import User from tinydb import Query from db.db import users_db import jwt +import random +import string +import smtplib +from backend.utils.email_instance import email_sender +from datetime import datetime, timedelta user_api = Blueprint('user_api', __name__) UserQuery = Query() @@ -31,6 +36,25 @@ def get_profile(): 'image_id': user.image_id }), 200 +@user_api.route('/user/profile', methods=['PUT']) +def update_profile(): + user = get_current_user() + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + data = request.get_json() + # Only allow first_name, last_name, image_id to be updated + first_name = data.get('first_name') + last_name = data.get('last_name') + image_id = data.get('image_id') + if first_name is not None: + user.first_name = first_name + if last_name is not None: + user.last_name = last_name + if image_id is not None: + user.image_id = image_id + users_db.update(user.to_dict(), UserQuery.email == user.email) + return jsonify({'message': 'Profile updated'}), 200 + @user_api.route('/user/image', methods=['PUT']) def update_image(): user = get_current_user() @@ -43,3 +67,82 @@ def update_image(): user.image_id = image_id users_db.update(user.to_dict(), UserQuery.email == user.email) return jsonify({'message': 'Image updated', 'image_id': image_id}), 200 + +@user_api.route('/user/check-pin', methods=['POST']) +def check_pin(): + user = get_current_user() + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + data = request.get_json() + pin = data.get('pin') + if not pin: + return jsonify({'error': 'Missing pin'}), 400 + if user.pin and pin == user.pin: + return jsonify({'valid': True}), 200 + return jsonify({'valid': False}), 200 + +@user_api.route('/user/has-pin', methods=['GET']) +def has_pin(): + user = get_current_user() + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + return jsonify({'has_pin': bool(user.pin)}), 200 + +@user_api.route('/user/request-pin-setup', methods=['POST']) +def request_pin_setup(): + user = get_current_user() + if not user or not user.verified: + return jsonify({'error': 'Unauthorized'}), 401 + # Generate 6-digit/character code + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) + user.pin_setup_code = code + user.pin_setup_code_created = datetime.utcnow().isoformat() + users_db.update(user.to_dict(), UserQuery.email == user.email) + # Send email + send_pin_setup_email(user.email, code) + return jsonify({'message': 'Verification code sent to your email.'}), 200 + +def send_pin_setup_email(email, code): + # Use the reusable email sender + email_sender.send_pin_setup_email(email, code) + +@user_api.route('/user/verify-pin-setup', methods=['POST']) +def verify_pin_setup(): + user = get_current_user() + if not user or not user.verified: + return jsonify({'error': 'Unauthorized'}), 401 + data = request.get_json() + code = data.get('code') + if not code: + return jsonify({'error': 'Missing code'}), 400 + if not user.pin_setup_code or not user.pin_setup_code_created: + return jsonify({'error': 'No code requested'}), 400 + # Check expiry (10 min) + created = datetime.fromisoformat(user.pin_setup_code_created) + if datetime.utcnow() > created + timedelta(minutes=10): + return jsonify({'error': 'Code expired'}), 400 + if code.strip().upper() != user.pin_setup_code.upper(): + return jsonify({'error': 'Invalid code'}), 400 + return jsonify({'message': 'Code verified'}), 200 + +@user_api.route('/user/set-pin', methods=['POST']) +def set_pin(): + user = get_current_user() + if not user or not user.verified: + return jsonify({'error': 'Unauthorized'}), 401 + data = request.get_json() + pin = data.get('pin') + if not pin or not pin.isdigit() or not (4 <= len(pin) <= 6): + return jsonify({'error': 'PIN must be 4-6 digits'}), 400 + # Only allow if code was recently verified + if not user.pin_setup_code or not user.pin_setup_code_created: + return jsonify({'error': 'No code verified'}), 400 + created = datetime.fromisoformat(user.pin_setup_code_created) + if datetime.utcnow() > created + timedelta(minutes=10): + return jsonify({'error': 'Code expired'}), 400 + # Set pin, clear code + user.pin = pin + user.pin_setup_code = '' + user.pin_setup_code_created = None + users_db.update(user.to_dict(), UserQuery.email == user.email) + return jsonify({'message': 'Parent PIN set'}), 200 diff --git a/backend/main.py b/backend/main.py index ed11933..11b92a4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,13 +4,15 @@ import sys from flask import Flask, request, jsonify from flask_cors import CORS -from api.auth_api import auth_api, mail +from api.auth_api import auth_api from api.child_api import child_api from api.image_api import image_api from api.reward_api import reward_api from api.task_api import task_api from api.user_api import user_api from config.version import get_full_version + +from backend.utils.email_instance import email_sender from db.default import initializeImages, createDefaultTasks, createDefaultRewards from events.broadcaster import Broadcaster from events.sse import sse_response_for_user, send_to_user @@ -44,10 +46,11 @@ app.config.update( FRONTEND_URL='https://localhost:5173', # Adjust as needed SECRET_KEY='supersecretkey' # Replace with a secure key in production ) -mail.init_app(app) CORS(app) +email_sender.init_app(app) + @app.route("/version") def api_version(): return jsonify({"version": get_full_version()}) @@ -84,6 +87,5 @@ createDefaultTasks() createDefaultRewards() start_background_threads() - if __name__ == '__main__': app.run(debug=False, host='0.0.0.0', port=5000, threaded=True) \ No newline at end of file diff --git a/backend/models/user.py b/backend/models/user.py index 25f74a0..cb86f61 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -13,6 +13,9 @@ class User(BaseModel): reset_token: str | None = None reset_token_created: str | None = None image_id: str | None = None + pin: str = '' + pin_setup_code: str = '' + pin_setup_code_created: str | None = None @classmethod def from_dict(cls, d: dict): @@ -27,6 +30,9 @@ class User(BaseModel): reset_token=d.get('reset_token'), reset_token_created=d.get('reset_token_created'), image_id=d.get('image_id'), + pin=d.get('pin', ''), + pin_setup_code=d.get('pin_setup_code', ''), + pin_setup_code_created=d.get('pin_setup_code_created'), id=d.get('id'), created_at=d.get('created_at'), updated_at=d.get('updated_at') @@ -45,6 +51,9 @@ class User(BaseModel): 'verify_token_created': self.verify_token_created, 'reset_token': self.reset_token, 'reset_token_created': self.reset_token_created, - 'image_id': self.image_id + 'image_id': self.image_id, + 'pin': self.pin, + 'pin_setup_code': self.pin_setup_code, + 'pin_setup_code_created': self.pin_setup_code_created }) return base diff --git a/backend/utils/email_instance.py b/backend/utils/email_instance.py new file mode 100644 index 0000000..179590c --- /dev/null +++ b/backend/utils/email_instance.py @@ -0,0 +1,3 @@ +from backend.utils.email_sender import EmailSender + +email_sender = EmailSender() \ No newline at end of file diff --git a/backend/utils/email_sender.py b/backend/utils/email_sender.py new file mode 100644 index 0000000..8e306f2 --- /dev/null +++ b/backend/utils/email_sender.py @@ -0,0 +1,65 @@ +import os +from flask import current_app +from flask_mail import Mail, Message +import smtplib +from email.mime.text import MIMEText + +class EmailSender: + def __init__(self, app=None): + self.mail = None + if app is not None: + self.init_app(app) + + def init_app(self, app): + self.mail = Mail(app) + + def send_verification_email(self, to_email, token): + verify_url = f"{current_app.config['FRONTEND_URL']}/auth/verify?token={token}" + html_body = f'Click here to verify your account.' + msg = Message( + subject="Verify your account", + recipients=[to_email], + html=html_body, + sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local') + ) + if self.mail: + self.mail.send(msg) + else: + print(f"[EMAIL to {to_email}] Verification: {verify_url}") + + def send_reset_password_email(self, to_email, token): + reset_url = f"{current_app.config['FRONTEND_URL']}/auth/reset-password?token={token}" + html_body = f'Click here to reset your password.' + msg = Message( + subject="Reset your password", + recipients=[to_email], + html=html_body, + sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local') + ) + if self.mail: + self.mail.send(msg) + else: + print(f"[EMAIL to {to_email}] Reset password: {reset_url}") + + def send_pin_setup_email(self, to_email, code): + html_body = f""" +
+

Set up your Parent PIN

+

To set your Parent PIN, enter the following code in the app:

+
{code}
+

This code is valid for 10 minutes.

+

If you did not request this, you can ignore this email.

+
+
Reward App
+
+ """ + msg = Message( + subject="Set up your Parent PIN", + recipients=[to_email], + html=html_body, + sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local') + ) + if self.mail: + self.mail.send(msg) + else: + print(f"[EMAIL to {to_email}] Parent PIN setup code: {code}") \ No newline at end of file diff --git a/frontend/vue-app/src/assets/edit-forms.css b/frontend/vue-app/src/assets/edit-forms.css index 3e7d815..15539c1 100644 --- a/frontend/vue-app/src/assets/edit-forms.css +++ b/frontend/vue-app/src/assets/edit-forms.css @@ -11,16 +11,6 @@ padding: 2rem 2.2rem 1.5rem 2.2rem; } -.profile-view h2, -.edit-view h2, -.child-edit-view h2, -.reward-edit-view h2, -.task-edit-view h2 { - text-align: center; - margin-bottom: 1.5rem; - color: var(--form-heading); -} - .profile-form, .task-form, .reward-form, diff --git a/frontend/vue-app/src/assets/styles.css b/frontend/vue-app/src/assets/styles.css index 7b686d4..5a15b80 100644 --- a/frontend/vue-app/src/assets/styles.css +++ b/frontend/vue-app/src/assets/styles.css @@ -1,3 +1,12 @@ +/* Edit view container for forms */ +.edit-view { + max-width: 400px; + margin: 2rem auto; + background: var(--form-bg); + border-radius: 12px; + box-shadow: 0 4px 24px var(--form-shadow); + padding: 2rem 2.2rem 1.5rem 2.2rem; +} /*buttons*/ .btn { font-weight: 600; @@ -98,3 +107,15 @@ background: var(--sign-in-btn-hover-bg); color: var(--sign-in-btn-hover-color); } + +/* Errors and info*/ +.info-message { + color: var(--info-points, #2563eb); + font-size: 1rem; + margin-top: 0.5rem; +} +.error-message { + color: var(--error, #e53e3e); + font-size: 1rem; + margin-top: 0.5rem; +} diff --git a/frontend/vue-app/src/components/auth/ParentPinSetup.vue b/frontend/vue-app/src/components/auth/ParentPinSetup.vue new file mode 100644 index 0000000..46b846a --- /dev/null +++ b/frontend/vue-app/src/components/auth/ParentPinSetup.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/frontend/vue-app/src/components/child/ChildEditView.vue b/frontend/vue-app/src/components/child/ChildEditView.vue index 79bf3bf..36ca249 100644 --- a/frontend/vue-app/src/components/child/ChildEditView.vue +++ b/frontend/vue-app/src/components/child/ChildEditView.vue @@ -1,21 +1,24 @@ + diff --git a/frontend/vue-app/src/components/profile/UserProfile.vue b/frontend/vue-app/src/components/profile/UserProfile.vue index 22ee309..74de895 100644 --- a/frontend/vue-app/src/components/profile/UserProfile.vue +++ b/frontend/vue-app/src/components/profile/UserProfile.vue @@ -1,86 +1,101 @@ diff --git a/frontend/vue-app/src/components/reward/RewardEditView.vue b/frontend/vue-app/src/components/reward/RewardEditView.vue index 6ca82d8..5b2d91e 100644 --- a/frontend/vue-app/src/components/reward/RewardEditView.vue +++ b/frontend/vue-app/src/components/reward/RewardEditView.vue @@ -1,21 +1,24 @@