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""" +
To set your Parent PIN, enter the following code in the app:
+This code is valid for 10 minutes.
+If you did not request this, you can ignore this email.
++ To protect your account, you need to set a Parent PIN. This PIN is required to access parent + features. +
+ + + ++ We've sent a 6-digit code to your email. Enter it below to continue. The code is valid for + 10 minutes. +
+ + +Enter a new 4–6 digit Parent PIN. This will be required for parent access.
+ + + + +Your Parent PIN has been set. You can now use it to access parent features.
+ +