feat: add parent PIN setup functionality and email notifications
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 23s
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 23s
- 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.
This commit is contained in:
@@ -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 <a href="{verify_url}">here</a> 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 <a href="{reset_url}">here</a> 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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
3
backend/utils/email_instance.py
Normal file
3
backend/utils/email_instance.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from backend.utils.email_sender import EmailSender
|
||||
|
||||
email_sender = EmailSender()
|
||||
65
backend/utils/email_sender.py
Normal file
65
backend/utils/email_sender.py
Normal file
@@ -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 <a href="{verify_url}">here</a> 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 <a href="{reset_url}">here</a> 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"""
|
||||
<div style='font-family:sans-serif;'>
|
||||
<h2>Set up your Parent PIN</h2>
|
||||
<p>To set your Parent PIN, enter the following code in the app:</p>
|
||||
<div style='font-size:2rem; font-weight:bold; letter-spacing:0.2em; margin:1.5rem 0;'>{code}</div>
|
||||
<p>This code is valid for 10 minutes.</p>
|
||||
<p>If you did not request this, you can ignore this email.</p>
|
||||
<hr>
|
||||
<div style='color:#888;font-size:0.95rem;'>Reward App</div>
|
||||
</div>
|
||||
"""
|
||||
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}")
|
||||
Reference in New Issue
Block a user