Compare commits
9 Commits
master
...
a89d3d7313
| Author | SHA1 | Date | |
|---|---|---|---|
| a89d3d7313 | |||
| 9a6fbced15 | |||
| 5b0fe2adc2 | |||
| fd1057662f | |||
| d7fc3c0cab | |||
| 1900667328 | |||
| 03356d813f | |||
| f65d97a50a | |||
| 46af0fb959 |
265
api/auth_api.py
Normal file
265
api/auth_api.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import logging
|
||||||
|
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 tinydb import Query
|
||||||
|
import os
|
||||||
|
|
||||||
|
from api.utils import sanitize_email
|
||||||
|
from config.paths import get_user_image_dir
|
||||||
|
|
||||||
|
from api.error_codes import MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING, \
|
||||||
|
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD, INVALID_CREDENTIALS, \
|
||||||
|
NOT_VERIFIED
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
@auth_api.route('/signup', methods=['POST'])
|
||||||
|
def signup():
|
||||||
|
data = request.get_json()
|
||||||
|
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
|
||||||
|
|
||||||
|
if users_db.search(UserQuery.email == data['email']):
|
||||||
|
return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400
|
||||||
|
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
now_iso = datetime.utcnow().isoformat()
|
||||||
|
user = User(
|
||||||
|
first_name=data['first_name'],
|
||||||
|
last_name=data['last_name'],
|
||||||
|
email=data['email'],
|
||||||
|
password=data['password'], # Hash in production!
|
||||||
|
verified=False,
|
||||||
|
verify_token=token,
|
||||||
|
verify_token_created=now_iso,
|
||||||
|
image_id="boy01"
|
||||||
|
)
|
||||||
|
users_db.insert(user.to_dict())
|
||||||
|
send_verification_email(data['email'], token)
|
||||||
|
return jsonify({'message': 'User created, verification email sent'}), 201
|
||||||
|
|
||||||
|
@auth_api.route('/verify', methods=['GET'])
|
||||||
|
def verify():
|
||||||
|
token = request.args.get('token')
|
||||||
|
status = 'success'
|
||||||
|
reason = ''
|
||||||
|
code = ''
|
||||||
|
user_dict = None
|
||||||
|
user = None
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
status = 'error'
|
||||||
|
reason = 'Missing token'
|
||||||
|
code = MISSING_TOKEN
|
||||||
|
else:
|
||||||
|
user_dict = users_db.get(Query().verify_token == token)
|
||||||
|
user = User.from_dict(user_dict) if user_dict else None
|
||||||
|
if not user:
|
||||||
|
status = 'error'
|
||||||
|
reason = 'Invalid token'
|
||||||
|
code = INVALID_TOKEN
|
||||||
|
else:
|
||||||
|
created_str = user.verify_token_created
|
||||||
|
if not created_str:
|
||||||
|
status = 'error'
|
||||||
|
reason = 'Token timestamp missing'
|
||||||
|
code = TOKEN_TIMESTAMP_MISSING
|
||||||
|
else:
|
||||||
|
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
|
||||||
|
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=TOKEN_EXPIRY_MINUTES):
|
||||||
|
status = 'error'
|
||||||
|
reason = 'Token expired'
|
||||||
|
code = TOKEN_EXPIRED
|
||||||
|
else:
|
||||||
|
user.verified = True
|
||||||
|
user.verify_token = None
|
||||||
|
user.verify_token_created = None
|
||||||
|
users_db.update(user.to_dict(), Query().email == user.email)
|
||||||
|
|
||||||
|
http_status = 200 if status == 'success' else 400
|
||||||
|
if http_status == 200 and user is not None:
|
||||||
|
if not user.email:
|
||||||
|
logger.error("Verified user has no email field.")
|
||||||
|
else:
|
||||||
|
user_image_dir = get_user_image_dir(sanitize_email(user.email))
|
||||||
|
os.makedirs(user_image_dir, exist_ok=True)
|
||||||
|
|
||||||
|
return jsonify({'status': status, 'reason': reason, 'code': code}), http_status
|
||||||
|
|
||||||
|
@auth_api.route('/resend-verify', methods=['POST'])
|
||||||
|
def resend_verify():
|
||||||
|
data = request.get_json()
|
||||||
|
email = data.get('email')
|
||||||
|
if not email:
|
||||||
|
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
|
||||||
|
|
||||||
|
user_dict = users_db.get(UserQuery.email == 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
|
||||||
|
|
||||||
|
if user.verified:
|
||||||
|
return jsonify({'error': 'Account already verified', 'code': ALREADY_VERIFIED}), 400
|
||||||
|
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
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)
|
||||||
|
return jsonify({'message': 'Verification email resent'}), 200
|
||||||
|
|
||||||
|
@auth_api.route('/login', methods=['POST'])
|
||||||
|
def login():
|
||||||
|
data = request.get_json()
|
||||||
|
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
|
||||||
|
|
||||||
|
user_dict = users_db.get(UserQuery.email == 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
|
||||||
|
|
||||||
|
if not user.verified:
|
||||||
|
return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'email': email,
|
||||||
|
'exp': datetime.utcnow() + timedelta(hours=24*7)
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
|
||||||
|
|
||||||
|
resp = jsonify({'message': 'Login successful'})
|
||||||
|
resp.set_cookie('token', token, httponly=True, secure=True, samesite='Strict')
|
||||||
|
return resp, 200
|
||||||
|
|
||||||
|
@auth_api.route('/me', methods=['GET'])
|
||||||
|
def me():
|
||||||
|
token = request.cookies.get('token')
|
||||||
|
if not token:
|
||||||
|
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||||
|
email = payload.get('email')
|
||||||
|
user_dict = users_db.get(UserQuery.email == 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
|
||||||
|
return jsonify({
|
||||||
|
'email': user.email,
|
||||||
|
'id': sanitize_email(user.email),
|
||||||
|
'first_name': user.first_name,
|
||||||
|
'last_name': user.last_name,
|
||||||
|
'verified': user.verified
|
||||||
|
}), 200
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 401
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 401
|
||||||
|
|
||||||
|
@auth_api.route('/request-password-reset', methods=['POST'])
|
||||||
|
def request_password_reset():
|
||||||
|
data = request.get_json()
|
||||||
|
email = data.get('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 = 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)
|
||||||
|
|
||||||
|
return jsonify({'message': success_msg}), 200
|
||||||
|
|
||||||
|
@auth_api.route('/validate-reset-token', methods=['GET'])
|
||||||
|
def validate_reset_token():
|
||||||
|
token = request.args.get('token')
|
||||||
|
if not token:
|
||||||
|
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 400
|
||||||
|
|
||||||
|
user_dict = users_db.get(UserQuery.reset_token == token)
|
||||||
|
user = User.from_dict(user_dict) if user_dict else None
|
||||||
|
if not user:
|
||||||
|
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400
|
||||||
|
|
||||||
|
created_str = user.reset_token_created
|
||||||
|
if not created_str:
|
||||||
|
return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400
|
||||||
|
|
||||||
|
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
|
||||||
|
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
|
||||||
|
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
|
||||||
|
|
||||||
|
return jsonify({'message': 'Token is valid'}), 200
|
||||||
|
|
||||||
|
@auth_api.route('/reset-password', methods=['POST'])
|
||||||
|
def reset_password():
|
||||||
|
data = request.get_json()
|
||||||
|
token = data.get('token')
|
||||||
|
new_password = data.get('password')
|
||||||
|
|
||||||
|
if not token or not new_password:
|
||||||
|
return jsonify({'error': 'Missing token or password'}), 400
|
||||||
|
|
||||||
|
user_dict = users_db.get(UserQuery.reset_token == token)
|
||||||
|
user = User.from_dict(user_dict) if user_dict else None
|
||||||
|
if not user:
|
||||||
|
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400
|
||||||
|
|
||||||
|
created_str = user.reset_token_created
|
||||||
|
if not created_str:
|
||||||
|
return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400
|
||||||
|
|
||||||
|
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
|
||||||
|
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
|
||||||
|
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
|
||||||
|
|
||||||
|
user.password = new_password # Hash in production!
|
||||||
|
user.reset_token = None
|
||||||
|
user.reset_token_created = None
|
||||||
|
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||||
|
|
||||||
|
return jsonify({'message': 'Password has been reset'}), 200
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
from db.db import child_db, task_db, reward_db, pending_reward_db
|
|
||||||
from api.reward_status import RewardStatus
|
|
||||||
from api.child_tasks import ChildTask
|
|
||||||
from api.child_rewards import ChildReward
|
from api.child_rewards import ChildReward
|
||||||
from events.sse import send_event_to_user
|
from api.child_tasks import ChildTask
|
||||||
|
from api.pending_reward import PendingReward as PendingRewardResponse
|
||||||
|
from api.reward_status import RewardStatus
|
||||||
|
from api.utils import send_event_for_current_user
|
||||||
|
from db.db import child_db, task_db, reward_db, pending_reward_db
|
||||||
from events.types.child_modified import ChildModified
|
from events.types.child_modified import ChildModified
|
||||||
from events.types.child_reward_request import ChildRewardRequest
|
from events.types.child_reward_request import ChildRewardRequest
|
||||||
from events.types.child_reward_triggered import ChildRewardTriggered
|
from events.types.child_reward_triggered import ChildRewardTriggered
|
||||||
@@ -13,12 +15,10 @@ from events.types.child_task_triggered import ChildTaskTriggered
|
|||||||
from events.types.child_tasks_set import ChildTasksSet
|
from events.types.child_tasks_set import ChildTasksSet
|
||||||
from events.types.event import Event
|
from events.types.event import Event
|
||||||
from events.types.event_types import EventType
|
from events.types.event_types import EventType
|
||||||
from api.pending_reward import PendingReward as PendingRewardResponse
|
|
||||||
|
|
||||||
from models.child import Child
|
from models.child import Child
|
||||||
from models.pending_reward import PendingReward
|
from models.pending_reward import PendingReward
|
||||||
from models.task import Task
|
|
||||||
from models.reward import Reward
|
from models.reward import Reward
|
||||||
|
from models.task import Task
|
||||||
|
|
||||||
child_api = Blueprint('child_api', __name__)
|
child_api = Blueprint('child_api', __name__)
|
||||||
|
|
||||||
@@ -44,7 +44,10 @@ def add_child():
|
|||||||
|
|
||||||
child = Child(name=name, age=age, image_id=image)
|
child = Child(name=name, age=age, image_id=image)
|
||||||
child_db.insert(child.to_dict())
|
child_db.insert(child.to_dict())
|
||||||
send_event_to_user("user123", Event(EventType.CHILD_MODIFIED.value, ChildModified(child.id, ChildModified.OPERATION_ADD)))
|
resp = send_event_for_current_user(
|
||||||
|
Event(EventType.CHILD_MODIFIED.value, ChildModified(child.id, ChildModified.OPERATION_ADD)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
return jsonify({'message': f'Child {name} added.'}), 201
|
return jsonify({'message': f'Child {name} added.'}), 201
|
||||||
|
|
||||||
@child_api.route('/child/<id>/edit', methods=['PUT'])
|
@child_api.route('/child/<id>/edit', methods=['PUT'])
|
||||||
@@ -85,16 +88,14 @@ def edit_child(id):
|
|||||||
pending_reward_db.remove(
|
pending_reward_db.remove(
|
||||||
(PendingQuery.child_id == id) & (PendingQuery.reward_id == reward.id)
|
(PendingQuery.child_id == id) & (PendingQuery.reward_id == reward.id)
|
||||||
)
|
)
|
||||||
send_event_to_user(
|
resp = send_event_for_current_user(
|
||||||
"user123",
|
Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(id, reward.id, ChildRewardRequest.REQUEST_CANCELLED)))
|
||||||
Event(
|
if resp:
|
||||||
EventType.CHILD_REWARD_REQUEST.value,
|
return resp
|
||||||
ChildRewardRequest(id, reward.id, ChildRewardRequest.REQUEST_CANCELLED)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
child_db.update(child.to_dict(), ChildQuery.id == id)
|
child_db.update(child.to_dict(), ChildQuery.id == id)
|
||||||
send_event_to_user("user123", Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_EDIT)))
|
resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_EDIT)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
return jsonify({'message': f'Child {id} updated.'}), 200
|
return jsonify({'message': f'Child {id} updated.'}), 200
|
||||||
|
|
||||||
@child_api.route('/child/list', methods=['GET'])
|
@child_api.route('/child/list', methods=['GET'])
|
||||||
@@ -107,8 +108,9 @@ def list_children():
|
|||||||
def delete_child(id):
|
def delete_child(id):
|
||||||
ChildQuery = Query()
|
ChildQuery = Query()
|
||||||
if child_db.remove(ChildQuery.id == id):
|
if child_db.remove(ChildQuery.id == id):
|
||||||
send_event_to_user("user123",
|
resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
|
||||||
Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
|
if resp:
|
||||||
|
return resp
|
||||||
return jsonify({'message': f'Child {id} deleted.'}), 200
|
return jsonify({'message': f'Child {id} deleted.'}), 200
|
||||||
return jsonify({'error': 'Child not found'}), 404
|
return jsonify({'error': 'Child not found'}), 404
|
||||||
|
|
||||||
@@ -154,7 +156,9 @@ def set_child_tasks(id):
|
|||||||
valid_task_ids.append(tid)
|
valid_task_ids.append(tid)
|
||||||
# Replace tasks with validated IDs
|
# Replace tasks with validated IDs
|
||||||
child_db.update({'tasks': valid_task_ids}, ChildQuery.id == id)
|
child_db.update({'tasks': valid_task_ids}, ChildQuery.id == id)
|
||||||
send_event_to_user("user123", Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, valid_task_ids)))
|
resp = send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, valid_task_ids)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'message': f'Tasks set for child {id}.',
|
'message': f'Tasks set for child {id}.',
|
||||||
'task_ids': valid_task_ids,
|
'task_ids': valid_task_ids,
|
||||||
@@ -302,8 +306,9 @@ def trigger_child_task(id):
|
|||||||
child.points = max(child.points, 0)
|
child.points = max(child.points, 0)
|
||||||
# update the child in the database
|
# update the child in the database
|
||||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
child_db.update({'points': child.points}, ChildQuery.id == id)
|
||||||
send_event_to_user("user123", Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task.id, child.id, child.points)))
|
resp = send_event_for_current_user(Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task.id, child.id, child.points)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
return jsonify({'message': f'{task.name} points assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
return jsonify({'message': f'{task.name} points assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
||||||
|
|
||||||
@child_api.route('/child/<id>/assign-reward', methods=['POST'])
|
@child_api.route('/child/<id>/assign-reward', methods=['POST'])
|
||||||
@@ -388,7 +393,9 @@ def set_child_rewards(id):
|
|||||||
|
|
||||||
# Replace rewards with validated IDs
|
# Replace rewards with validated IDs
|
||||||
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
|
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
|
||||||
send_event_to_user("user123", Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, valid_reward_ids)))
|
resp = send_event_for_current_user(Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, valid_reward_ids)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'message': f'Rewards set for child {id}.',
|
'message': f'Rewards set for child {id}.',
|
||||||
'reward_ids': valid_reward_ids,
|
'reward_ids': valid_reward_ids,
|
||||||
@@ -488,16 +495,18 @@ def trigger_child_reward(id):
|
|||||||
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward.id)
|
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward.id)
|
||||||
)
|
)
|
||||||
if removed:
|
if removed:
|
||||||
send_event_to_user("user123", Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
|
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
# update the child's points based on reward cost
|
# update the child's points based on reward cost
|
||||||
child.points -= reward.cost
|
child.points -= reward.cost
|
||||||
# update the child in the database
|
# update the child in the database
|
||||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
child_db.update({'points': child.points}, ChildQuery.id == id)
|
||||||
send_event_to_user("user123", Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
|
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
||||||
|
|
||||||
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
|
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
|
||||||
@@ -580,8 +589,9 @@ def request_reward(id):
|
|||||||
|
|
||||||
pending = PendingReward(child_id=child.id, reward_id=reward.id)
|
pending = PendingReward(child_id=child.id, reward_id=reward.id)
|
||||||
pending_reward_db.insert(pending.to_dict())
|
pending_reward_db.insert(pending.to_dict())
|
||||||
send_event_to_user("user123", Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
|
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'message': f'Reward request for {reward.name} submitted for {child.name}.',
|
'message': f'Reward request for {reward.name} submitted for {child.name}.',
|
||||||
'reward_id': reward.id,
|
'reward_id': reward.id,
|
||||||
@@ -615,14 +625,9 @@ def cancel_request_reward(id):
|
|||||||
return jsonify({'error': 'No pending request found for this reward'}), 404
|
return jsonify({'error': 'No pending request found for this reward'}), 404
|
||||||
|
|
||||||
# Notify user that the request was cancelled
|
# Notify user that the request was cancelled
|
||||||
send_event_to_user(
|
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)))
|
||||||
"user123",
|
if resp:
|
||||||
Event(
|
return resp
|
||||||
EventType.CHILD_REWARD_REQUEST.value,
|
|
||||||
ChildRewardRequest(child.id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'message': f'Reward request cancelled for {child.name}.',
|
'message': f'Reward request cancelled for {child.name}.',
|
||||||
'child_id': child.id,
|
'child_id': child.id,
|
||||||
|
|||||||
12
api/error_codes.py
Normal file
12
api/error_codes.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
MISSING_FIELDS = "MISSING_FIELDS"
|
||||||
|
EMAIL_EXISTS = "EMAIL_EXISTS"
|
||||||
|
MISSING_TOKEN = "MISSING_TOKEN"
|
||||||
|
INVALID_TOKEN = "INVALID_TOKEN"
|
||||||
|
TOKEN_TIMESTAMP_MISSING = "TOKEN_TIMESTAMP_MISSING"
|
||||||
|
TOKEN_EXPIRED = "TOKEN_EXPIRED"
|
||||||
|
MISSING_EMAIL = "MISSING_EMAIL"
|
||||||
|
USER_NOT_FOUND = "USER_NOT_FOUND"
|
||||||
|
ALREADY_VERIFIED = "ALREADY_VERIFIED"
|
||||||
|
MISSING_EMAIL_OR_PASSWORD = "MISSING_EMAIL_OR_PASSWORD"
|
||||||
|
INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
|
||||||
|
NOT_VERIFIED = "NOT_VERIFIED"
|
||||||
@@ -3,13 +3,14 @@ import os
|
|||||||
from PIL import Image as PILImage, UnidentifiedImageError
|
from PIL import Image as PILImage, UnidentifiedImageError
|
||||||
from flask import Blueprint, request, jsonify, send_file
|
from flask import Blueprint, request, jsonify, send_file
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
|
|
||||||
|
from api.utils import get_current_user_id, sanitize_email
|
||||||
from config.paths import get_user_image_dir
|
from config.paths import get_user_image_dir
|
||||||
|
|
||||||
from db.db import image_db
|
from db.db import image_db
|
||||||
from models.image import Image
|
from models.image import Image
|
||||||
|
|
||||||
image_api = Blueprint('image_api', __name__)
|
image_api = Blueprint('image_api', __name__)
|
||||||
UPLOAD_FOLDER = get_user_image_dir("user123")
|
|
||||||
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png'}
|
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png'}
|
||||||
IMAGE_TYPE_PROFILE = 1
|
IMAGE_TYPE_PROFILE = 1
|
||||||
IMAGE_TYPE_ICON = 2
|
IMAGE_TYPE_ICON = 2
|
||||||
@@ -20,6 +21,9 @@ def allowed_file(filename):
|
|||||||
|
|
||||||
@image_api.route('/image/upload', methods=['POST'])
|
@image_api.route('/image/upload', methods=['POST'])
|
||||||
def upload():
|
def upload():
|
||||||
|
user_id = get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'User not authenticated'}), 401
|
||||||
if 'file' not in request.files:
|
if 'file' not in request.files:
|
||||||
return jsonify({'error': 'No file part in the request'}), 400
|
return jsonify({'error': 'No file part in the request'}), 400
|
||||||
file = request.files['file']
|
file = request.files['file']
|
||||||
@@ -60,13 +64,11 @@ def upload():
|
|||||||
|
|
||||||
format_extension_map = {'JPEG': '.jpg', 'PNG': '.png'}
|
format_extension_map = {'JPEG': '.jpg', 'PNG': '.png'}
|
||||||
extension = format_extension_map.get(original_format, '.png')
|
extension = format_extension_map.get(original_format, '.png')
|
||||||
|
image_record = Image(extension=extension, permanent=perm, type=image_type, user=user_id)
|
||||||
image_record = Image(extension=extension, permanent=perm, type=image_type, user="user123")
|
|
||||||
filename = image_record.id + extension
|
filename = image_record.id + extension
|
||||||
filepath = os.path.abspath(os.path.join(UPLOAD_FOLDER, filename))
|
filepath = os.path.abspath(os.path.join(get_user_image_dir(sanitize_email(user_id)), filename))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
|
||||||
# Save with appropriate format
|
# Save with appropriate format
|
||||||
save_params = {}
|
save_params = {}
|
||||||
if pil_image.format == 'JPEG':
|
if pil_image.format == 'JPEG':
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
|
|
||||||
from events.sse import send_event_to_user
|
from api.utils import send_event_for_current_user
|
||||||
|
from db.db import reward_db, child_db
|
||||||
from events.types.event import Event
|
from events.types.event import Event
|
||||||
from events.types.event_types import EventType
|
from events.types.event_types import EventType
|
||||||
from events.types.reward_modified import RewardModified
|
from events.types.reward_modified import RewardModified
|
||||||
from models.reward import Reward
|
from models.reward import Reward
|
||||||
from db.db import reward_db, child_db
|
|
||||||
|
|
||||||
reward_api = Blueprint('reward_api', __name__)
|
reward_api = Blueprint('reward_api', __name__)
|
||||||
|
|
||||||
@@ -22,9 +22,10 @@ def add_reward():
|
|||||||
return jsonify({'error': 'Name, description, and cost are required'}), 400
|
return jsonify({'error': 'Name, description, and cost are required'}), 400
|
||||||
reward = Reward(name=name, description=description, cost=cost, image_id=image)
|
reward = Reward(name=name, description=description, cost=cost, image_id=image)
|
||||||
reward_db.insert(reward.to_dict())
|
reward_db.insert(reward.to_dict())
|
||||||
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
|
resp = send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
|
||||||
RewardModified(reward.id, RewardModified.OPERATION_ADD)))
|
RewardModified(reward.id, RewardModified.OPERATION_ADD)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
return jsonify({'message': f'Reward {name} added.'}), 201
|
return jsonify({'message': f'Reward {name} added.'}), 201
|
||||||
|
|
||||||
|
|
||||||
@@ -54,9 +55,10 @@ def delete_reward(id):
|
|||||||
if id in rewards:
|
if id in rewards:
|
||||||
rewards.remove(id)
|
rewards.remove(id)
|
||||||
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
|
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
|
||||||
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
|
resp = send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
|
||||||
RewardModified(id, RewardModified.OPERATION_DELETE)))
|
RewardModified(id, RewardModified.OPERATION_DELETE)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
return jsonify({'message': f'Reward {id} deleted.'}), 200
|
return jsonify({'message': f'Reward {id} deleted.'}), 200
|
||||||
return jsonify({'error': 'Reward not found'}), 404
|
return jsonify({'error': 'Reward not found'}), 404
|
||||||
|
|
||||||
@@ -98,7 +100,9 @@ def edit_reward(id):
|
|||||||
|
|
||||||
reward_db.update(updates, RewardQuery.id == id)
|
reward_db.update(updates, RewardQuery.id == id)
|
||||||
updated = reward_db.get(RewardQuery.id == id)
|
updated = reward_db.get(RewardQuery.id == id)
|
||||||
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
|
resp = send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
|
||||||
RewardModified(id, RewardModified.OPERATION_EDIT)))
|
RewardModified(id, RewardModified.OPERATION_EDIT)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
|
|
||||||
return jsonify(updated), 200
|
return jsonify(updated), 200
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
|
|
||||||
from events.sse import send_event_to_user
|
from api.utils import send_event_for_current_user
|
||||||
|
from db.db import task_db, child_db
|
||||||
from events.types.event import Event
|
from events.types.event import Event
|
||||||
from events.types.event_types import EventType
|
from events.types.event_types import EventType
|
||||||
from events.types.task_modified import TaskModified
|
from events.types.task_modified import TaskModified
|
||||||
from models.task import Task
|
from models.task import Task
|
||||||
from db.db import task_db, child_db
|
|
||||||
|
|
||||||
task_api = Blueprint('task_api', __name__)
|
task_api = Blueprint('task_api', __name__)
|
||||||
|
|
||||||
@@ -22,8 +22,10 @@ def add_task():
|
|||||||
return jsonify({'error': 'Name, points, and is_good are required'}), 400
|
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 = Task(name=name, points=points, is_good=is_good, image_id=image)
|
||||||
task_db.insert(task.to_dict())
|
task_db.insert(task.to_dict())
|
||||||
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
|
resp = send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||||
TaskModified(task.id, TaskModified.OPERATION_ADD)))
|
TaskModified(task.id, TaskModified.OPERATION_ADD)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
return jsonify({'message': f'Task {name} added.'}), 201
|
return jsonify({'message': f'Task {name} added.'}), 201
|
||||||
|
|
||||||
@task_api.route('/task/<id>', methods=['GET'])
|
@task_api.route('/task/<id>', methods=['GET'])
|
||||||
@@ -51,8 +53,10 @@ def delete_task(id):
|
|||||||
if id in tasks:
|
if id in tasks:
|
||||||
tasks.remove(id)
|
tasks.remove(id)
|
||||||
child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id'))
|
child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id'))
|
||||||
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
|
resp = send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||||
TaskModified(id, TaskModified.OPERATION_DELETE)))
|
TaskModified(id, TaskModified.OPERATION_DELETE)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
|
|
||||||
return jsonify({'message': f'Task {id} deleted.'}), 200
|
return jsonify({'message': f'Task {id} deleted.'}), 200
|
||||||
return jsonify({'error': 'Task not found'}), 404
|
return jsonify({'error': 'Task not found'}), 404
|
||||||
@@ -95,6 +99,8 @@ def edit_task(id):
|
|||||||
|
|
||||||
task_db.update(updates, TaskQuery.id == id)
|
task_db.update(updates, TaskQuery.id == id)
|
||||||
updated = task_db.get(TaskQuery.id == id)
|
updated = task_db.get(TaskQuery.id == id)
|
||||||
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
|
resp = send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||||
TaskModified(id, TaskModified.OPERATION_EDIT)))
|
TaskModified(id, TaskModified.OPERATION_EDIT)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
return jsonify(updated), 200
|
return jsonify(updated), 200
|
||||||
|
|||||||
45
api/user_api.py
Normal file
45
api/user_api.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
from models.user import User
|
||||||
|
from tinydb import Query
|
||||||
|
from db.db import users_db
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
user_api = Blueprint('user_api', __name__)
|
||||||
|
UserQuery = Query()
|
||||||
|
|
||||||
|
def get_current_user():
|
||||||
|
token = request.cookies.get('token')
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||||
|
email = payload.get('email')
|
||||||
|
user_dict = users_db.get(UserQuery.email == email)
|
||||||
|
return User.from_dict(user_dict) if user_dict else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@user_api.route('/user/profile', methods=['GET'])
|
||||||
|
def get_profile():
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
|
return jsonify({
|
||||||
|
'first_name': user.first_name,
|
||||||
|
'last_name': user.last_name,
|
||||||
|
'email': user.email,
|
||||||
|
'image_id': user.image_id
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
@user_api.route('/user/image', methods=['PUT'])
|
||||||
|
def update_image():
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
|
data = request.get_json()
|
||||||
|
image_id = data.get('image_id')
|
||||||
|
if not image_id:
|
||||||
|
return jsonify({'error': 'Missing image_id'}), 400
|
||||||
|
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
|
||||||
28
api/utils.py
Normal file
28
api/utils.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import jwt
|
||||||
|
from flask import request, current_app, jsonify
|
||||||
|
|
||||||
|
from events.sse import send_event_to_user
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_email(email):
|
||||||
|
return email.replace('@', '_at_').replace('.', '_dot_')
|
||||||
|
|
||||||
|
def get_current_user_id():
|
||||||
|
token = request.cookies.get('token')
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||||
|
email = payload.get('email')
|
||||||
|
if not email:
|
||||||
|
return None
|
||||||
|
return sanitize_email(email)
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def send_event_for_current_user(event):
|
||||||
|
user_id = get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
|
send_event_to_user(user_id, event)
|
||||||
|
return None
|
||||||
4
db/db.py
4
db/db.py
@@ -72,6 +72,7 @@ task_path = os.path.join(base_dir, 'tasks.json')
|
|||||||
reward_path = os.path.join(base_dir, 'rewards.json')
|
reward_path = os.path.join(base_dir, 'rewards.json')
|
||||||
image_path = os.path.join(base_dir, 'images.json')
|
image_path = os.path.join(base_dir, 'images.json')
|
||||||
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
|
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
|
||||||
|
users_path = os.path.join(base_dir, 'users.json')
|
||||||
|
|
||||||
# Use separate TinyDB instances/files for each collection
|
# Use separate TinyDB instances/files for each collection
|
||||||
_child_db = TinyDB(child_path, indent=2)
|
_child_db = TinyDB(child_path, indent=2)
|
||||||
@@ -79,6 +80,7 @@ _task_db = TinyDB(task_path, indent=2)
|
|||||||
_reward_db = TinyDB(reward_path, indent=2)
|
_reward_db = TinyDB(reward_path, indent=2)
|
||||||
_image_db = TinyDB(image_path, indent=2)
|
_image_db = TinyDB(image_path, indent=2)
|
||||||
_pending_rewards_db = TinyDB(pending_reward_path, indent=2)
|
_pending_rewards_db = TinyDB(pending_reward_path, indent=2)
|
||||||
|
_users_db = TinyDB(users_path, indent=2)
|
||||||
|
|
||||||
# Expose table objects wrapped with locking
|
# Expose table objects wrapped with locking
|
||||||
child_db = LockedTable(_child_db)
|
child_db = LockedTable(_child_db)
|
||||||
@@ -86,6 +88,7 @@ task_db = LockedTable(_task_db)
|
|||||||
reward_db = LockedTable(_reward_db)
|
reward_db = LockedTable(_reward_db)
|
||||||
image_db = LockedTable(_image_db)
|
image_db = LockedTable(_image_db)
|
||||||
pending_reward_db = LockedTable(_pending_rewards_db)
|
pending_reward_db = LockedTable(_pending_rewards_db)
|
||||||
|
users_db = LockedTable(_users_db)
|
||||||
|
|
||||||
if os.environ.get('DB_ENV', 'prod') == 'test':
|
if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||||
child_db.truncate()
|
child_db.truncate()
|
||||||
@@ -93,4 +96,5 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
|
|||||||
reward_db.truncate()
|
reward_db.truncate()
|
||||||
image_db.truncate()
|
image_db.truncate()
|
||||||
pending_reward_db.truncate()
|
pending_reward_db.truncate()
|
||||||
|
users_db.truncate()
|
||||||
|
|
||||||
|
|||||||
25
main.py
25
main.py
@@ -1,16 +1,19 @@
|
|||||||
import sys, logging, os
|
import logging
|
||||||
from config.paths import get_user_image_dir
|
import sys
|
||||||
|
|
||||||
from flask import Flask, request, jsonify
|
from flask import Flask, request, jsonify
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
from api.auth_api import auth_api, mail
|
||||||
from api.child_api import child_api
|
from api.child_api import child_api
|
||||||
from api.image_api import image_api
|
from api.image_api import image_api
|
||||||
from api.reward_api import reward_api
|
from api.reward_api import reward_api
|
||||||
from api.task_api import task_api
|
from api.task_api import task_api
|
||||||
|
from api.user_api import user_api
|
||||||
from config.version import get_full_version
|
from config.version import get_full_version
|
||||||
|
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
|
||||||
from events.broadcaster import Broadcaster
|
from events.broadcaster import Broadcaster
|
||||||
from events.sse import sse_response_for_user, send_to_user
|
from events.sse import sse_response_for_user, send_to_user
|
||||||
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
|
|
||||||
|
|
||||||
# Configure logging once at application startup
|
# Configure logging once at application startup
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -28,6 +31,21 @@ app.register_blueprint(child_api)
|
|||||||
app.register_blueprint(reward_api)
|
app.register_blueprint(reward_api)
|
||||||
app.register_blueprint(task_api)
|
app.register_blueprint(task_api)
|
||||||
app.register_blueprint(image_api)
|
app.register_blueprint(image_api)
|
||||||
|
app.register_blueprint(auth_api)
|
||||||
|
app.register_blueprint(user_api)
|
||||||
|
|
||||||
|
app.config.update(
|
||||||
|
MAIL_SERVER='smtp.gmail.com',
|
||||||
|
MAIL_PORT=587,
|
||||||
|
MAIL_USE_TLS=True,
|
||||||
|
MAIL_USERNAME='ryan.kegel@gmail.com',
|
||||||
|
MAIL_PASSWORD='ruyj hxjf nmrz buar',
|
||||||
|
MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com',
|
||||||
|
FRONTEND_URL='https://localhost:5173', # Adjust as needed
|
||||||
|
SECRET_KEY='supersecretkey' # Replace with a secure key in production
|
||||||
|
)
|
||||||
|
mail.init_app(app)
|
||||||
|
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
||||||
@app.route("/version")
|
@app.route("/version")
|
||||||
@@ -61,7 +79,6 @@ def start_background_threads():
|
|||||||
broadcaster.start()
|
broadcaster.start()
|
||||||
|
|
||||||
# TODO: implement users
|
# TODO: implement users
|
||||||
os.makedirs(get_user_image_dir("user123"), exist_ok=True)
|
|
||||||
initializeImages()
|
initializeImages()
|
||||||
createDefaultTasks()
|
createDefaultTasks()
|
||||||
createDefaultRewards()
|
createDefaultRewards()
|
||||||
|
|||||||
50
models/user.py
Normal file
50
models/user.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from models.base import BaseModel
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class User(BaseModel):
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
email: str
|
||||||
|
password: str # In production, this should be hashed
|
||||||
|
verified: bool = False
|
||||||
|
verify_token: str | None = None
|
||||||
|
verify_token_created: str | None = None
|
||||||
|
reset_token: str | None = None
|
||||||
|
reset_token_created: str | None = None
|
||||||
|
image_id: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict):
|
||||||
|
return cls(
|
||||||
|
first_name=d.get('first_name'),
|
||||||
|
last_name=d.get('last_name'),
|
||||||
|
email=d.get('email'),
|
||||||
|
password=d.get('password'),
|
||||||
|
verified=d.get('verified', False),
|
||||||
|
verify_token=d.get('verify_token'),
|
||||||
|
verify_token_created=d.get('verify_token_created'),
|
||||||
|
reset_token=d.get('reset_token'),
|
||||||
|
reset_token_created=d.get('reset_token_created'),
|
||||||
|
image_id=d.get('image_id'),
|
||||||
|
id=d.get('id'),
|
||||||
|
created_at=d.get('created_at'),
|
||||||
|
updated_at=d.get('updated_at')
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
base = super().to_dict()
|
||||||
|
base.update({
|
||||||
|
'first_name': self.first_name,
|
||||||
|
'last_name': self.last_name,
|
||||||
|
'email': self.email,
|
||||||
|
'password': self.password,
|
||||||
|
'verified': self.verified,
|
||||||
|
'verify_token': self.verify_token,
|
||||||
|
'verify_token_created': self.verify_token_created,
|
||||||
|
'reset_token': self.reset_token,
|
||||||
|
'reset_token_created': self.reset_token_created,
|
||||||
|
'image_id': self.image_id
|
||||||
|
})
|
||||||
|
return base
|
||||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
@@ -1,12 +1,15 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useBackendEvents } from './common/backendEvents'
|
|
||||||
useBackendEvents('user123')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<BackendEventsListener />
|
||||||
<router-view />
|
<router-view />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import BackendEventsListener from '@/components/BackendEventsListener.vue'
|
||||||
|
import { checkAuth } from '@/stores/auth'
|
||||||
|
|
||||||
|
checkAuth()
|
||||||
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -35,3 +35,22 @@
|
|||||||
min-width: 90px;
|
min-width: 90px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Error message */
|
||||||
|
.error-message {
|
||||||
|
color: var(--error, #e53e3e);
|
||||||
|
font-size: 0.98rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success message */
|
||||||
|
.success-message {
|
||||||
|
color: var(--success, #16a34a);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input error */
|
||||||
|
.input-error {
|
||||||
|
border-color: var(--error, #e53e3e);
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,13 @@
|
|||||||
.btn-primary:focus {
|
.btn-primary:focus {
|
||||||
background: var(--btn-primary-hover);
|
background: var(--btn-primary-hover);
|
||||||
}
|
}
|
||||||
|
.btn-primary:disabled,
|
||||||
|
.btn-primary[disabled] {
|
||||||
|
background: var(--btn-secondary, #f3f3f3);
|
||||||
|
color: var(--btn-secondary-text, #666);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
/* Secondary button (e.g., Cancel) */
|
/* Secondary button (e.g., Cancel) */
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@@ -52,3 +59,91 @@
|
|||||||
.btn-green:focus {
|
.btn-green:focus {
|
||||||
background: var(--btn-green-hover);
|
background: var(--btn-green-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-btn {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--btn-primary, #667eea);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.12);
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
transform 0.06s;
|
||||||
|
}
|
||||||
|
.form-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.form-btn:hover:not(:disabled) {
|
||||||
|
background: var(--btn-primary-hover, #5a67d8);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link-style button */
|
||||||
|
.btn-link {
|
||||||
|
color: var(--btn-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.btn-link:disabled {
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0.75;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--btn-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-btn {
|
||||||
|
background: var(--sign-in-btn-bg);
|
||||||
|
color: var(--sign-in-btn-color);
|
||||||
|
border: 2px solid var(--sign-in-btn-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
margin-right: 0.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.18s,
|
||||||
|
color 0.18s;
|
||||||
|
}
|
||||||
|
.round-btn:hover {
|
||||||
|
background: var(--sign-in-btn-hover-bg);
|
||||||
|
color: var(--sign-in-btn-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating Action Button (FAB) */
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
background: var(--fab-bg);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 24px;
|
||||||
|
z-index: 1300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover {
|
||||||
|
background: var(--fab-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:active {
|
||||||
|
background: var(--fab-active-bg);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,15 +14,6 @@
|
|||||||
color: var(--child-list-title-color, #fff);
|
color: var(--child-list-title-color, #fff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading,
|
|
||||||
.empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
color: var(--child-list-loading-color, #fff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-wrapper {
|
.scroll-wrapper {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
|
|||||||
29
web/vue-app/src/assets/common.css
Normal file
29
web/vue-app/src/assets/common.css
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
.loading,
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
color: var(--child-list-loading-color, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--error);
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--error-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: var(--error, #e53e3e);
|
||||||
|
font-size: 0.98rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
color: var(--success, #16a34a);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
.profile-view,
|
||||||
.edit-view,
|
.edit-view,
|
||||||
.child-edit-view,
|
.child-edit-view,
|
||||||
.reward-edit-view,
|
.reward-edit-view,
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-view h2,
|
||||||
.edit-view h2,
|
.edit-view h2,
|
||||||
.child-edit-view h2,
|
.child-edit-view h2,
|
||||||
.reward-edit-view h2,
|
.reward-edit-view h2,
|
||||||
@@ -19,22 +21,38 @@
|
|||||||
color: var(--form-heading);
|
color: var(--form-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group,
|
.profile-form,
|
||||||
.reward-form label,
|
.task-form,
|
||||||
.task-form label {
|
.reward-form,
|
||||||
display: block;
|
.child-edit-form {
|
||||||
margin-bottom: 1.1rem;
|
display: flex;
|
||||||
font-weight: 500;
|
flex-direction: column;
|
||||||
color: var(--form-label);
|
gap: 1.1rem;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='text'],
|
.profile-form div.group,
|
||||||
input[type='number'],
|
.task-form div.group,
|
||||||
.reward-form input[type='text'],
|
.reward-form div.group,
|
||||||
.reward-form input[type='number'],
|
.child-edit-form div.group {
|
||||||
.task-form input[type='text'],
|
width: 100%;
|
||||||
.task-form input[type='number'] {
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-form div.group label,
|
||||||
|
.task-form div.group label,
|
||||||
|
.reward-form div.group label,
|
||||||
|
.child-edit-form div.group label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--form-label-color);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.group input[type='text'],
|
||||||
|
div.group input[type='number'],
|
||||||
|
div.group input[type='email'] {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
@@ -45,9 +63,18 @@ input[type='number'],
|
|||||||
background: var(--form-input-bg);
|
background: var(--form-input-bg);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
div.group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border: 1.5px solid var(--form-input-focus);
|
||||||
|
}
|
||||||
|
|
||||||
.loading-message {
|
.loading-message {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--form-loading);
|
color: var(--form-loading);
|
||||||
margin-bottom: 1.2rem;
|
margin-bottom: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-group.image-picker-group {
|
||||||
|
display: block;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,8 +71,8 @@
|
|||||||
--fab-bg: #667eea;
|
--fab-bg: #667eea;
|
||||||
--fab-hover-bg: #5a67d8;
|
--fab-hover-bg: #5a67d8;
|
||||||
--fab-active-bg: #4c51bf;
|
--fab-active-bg: #4c51bf;
|
||||||
--no-children-color: #fdfdfd;
|
--message-block-color: #fdfdfd;
|
||||||
--sub-message-color: #b5ccff;
|
--sub-message-color: #c1d0f1;
|
||||||
--sign-in-btn-bg: #fff;
|
--sign-in-btn-bg: #fff;
|
||||||
--sign-in-btn-color: #2563eb;
|
--sign-in-btn-color: #2563eb;
|
||||||
--sign-in-btn-border: #2563eb;
|
--sign-in-btn-border: #2563eb;
|
||||||
|
|||||||
@@ -14,57 +14,6 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* List item */
|
|
||||||
.list-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
border: 2px outset var(--list-item-border-good);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.2rem 1rem;
|
|
||||||
background: var(--list-item-bg);
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: border 0.18s;
|
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
margin-left: 0.2rem;
|
|
||||||
margin-right: 0.2rem;
|
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.list-item.bad {
|
|
||||||
border-color: var(--list-item-border-bad);
|
|
||||||
background: var(--list-item-bg-bad);
|
|
||||||
}
|
|
||||||
.list-item.good {
|
|
||||||
border-color: var(--list-item-border-good);
|
|
||||||
background: var(--list-item-bg-good);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Image styles */
|
|
||||||
.list-image {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-right: 0.7rem;
|
|
||||||
background: var(--list-image-bg);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Name/label styles */
|
|
||||||
.list-name {
|
|
||||||
flex: 1;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Points/cost/requested text */
|
|
||||||
.list-value {
|
|
||||||
min-width: 60px;
|
|
||||||
text-align: right;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Delete button */
|
/* Delete button */
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
25
web/vue-app/src/assets/modal.css
Normal file
25
web/vue-app/src/assets/modal.css
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--modal-bg);
|
||||||
|
color: var(--modal-text);
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 360px;
|
||||||
|
max-width: calc(100% - 32px);
|
||||||
|
box-shadow: var(--modal-shadow);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h3 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
25
web/vue-app/src/assets/scroll.css
Normal file
25
web/vue-app/src/assets/scroll.css
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
.scroll-wrapper::-webkit-scrollbar {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-wrapper::-webkit-scrollbar-track {
|
||||||
|
background: var(--child-list-scrollbar-track, rgba(255, 255, 255, 0.05));
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||||
|
background: var(
|
||||||
|
--child-list-scrollbar-thumb,
|
||||||
|
linear-gradient(180deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8))
|
||||||
|
);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: var(--child-list-scrollbar-thumb-border, 2px solid rgba(255, 255, 255, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(
|
||||||
|
--child-list-scrollbar-thumb-hover,
|
||||||
|
linear-gradient(180deg, rgba(102, 126, 234, 1), rgba(118, 75, 162, 1))
|
||||||
|
);
|
||||||
|
box-shadow: 0 0 8px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
@@ -94,21 +94,6 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-message {
|
|
||||||
margin: 2rem 0;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: center;
|
|
||||||
color: #fdfdfd;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.sub-message {
|
|
||||||
margin-top: 0.3rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #b5ccff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Floating Action Button (FAB) */
|
/* Floating Action Button (FAB) */
|
||||||
.fab {
|
.fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
17
web/vue-app/src/common/api.ts
Normal file
17
web/vue-app/src/common/api.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export async function parseErrorResponse(res: Response): Promise<{ msg: string; code?: string }> {
|
||||||
|
try {
|
||||||
|
const data = await res.json()
|
||||||
|
return { msg: data.error || data.message || 'Error', code: data.code }
|
||||||
|
} catch {
|
||||||
|
const text = await res.text()
|
||||||
|
return { msg: text || 'Error' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEmailValid(email: string): boolean {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPasswordStrong(password: string): boolean {
|
||||||
|
return /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{8,}$/.test(password)
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { onMounted, onBeforeUnmount } from 'vue'
|
import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
import { eventBus } from './eventBus'
|
import { eventBus } from './eventBus'
|
||||||
|
|
||||||
export function useBackendEvents(userId: string) {
|
export function useBackendEvents(userId: Ref<string>) {
|
||||||
let eventSource: EventSource | null = null
|
let eventSource: EventSource | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
const connect = () => {
|
||||||
console.log('Connecting to backend events for user:', userId)
|
if (eventSource) eventSource.close()
|
||||||
eventSource = new EventSource(`/events?user_id=${userId}`)
|
if (userId.value) {
|
||||||
|
console.log('Connecting to backend events for user:', userId.value)
|
||||||
|
eventSource = new EventSource(`/events?user_id=${userId.value}`)
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data)
|
const data = JSON.parse(event.data)
|
||||||
@@ -15,10 +18,13 @@ export function useBackendEvents(userId: string) {
|
|||||||
eventBus.emit(data.type, data)
|
eventBus.emit(data.type, data)
|
||||||
eventBus.emit('sse', data) // optional: catch-all channel
|
eventBus.emit('sse', data) // optional: catch-all channel
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(connect)
|
||||||
|
watch(userId, connect)
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
console.log('Disconnecting from backend events for user:', userId)
|
console.log('Disconnecting from backend events for user:', userId.value)
|
||||||
eventSource?.close()
|
eventSource?.close()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
12
web/vue-app/src/common/errorCodes.ts
Normal file
12
web/vue-app/src/common/errorCodes.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export const MISSING_FIELDS = 'MISSING_FIELDS'
|
||||||
|
export const EMAIL_EXISTS = 'EMAIL_EXISTS'
|
||||||
|
export const MISSING_TOKEN = 'MISSING_TOKEN'
|
||||||
|
export const INVALID_TOKEN = 'INVALID_TOKEN'
|
||||||
|
export const TOKEN_TIMESTAMP_MISSING = 'TOKEN_TIMESTAMP_MISSING'
|
||||||
|
export const TOKEN_EXPIRED = 'TOKEN_EXPIRED'
|
||||||
|
export const MISSING_EMAIL = 'MISSING_EMAIL'
|
||||||
|
export const USER_NOT_FOUND = 'USER_NOT_FOUND'
|
||||||
|
export const ALREADY_VERIFIED = 'ALREADY_VERIFIED'
|
||||||
|
export const MISSING_EMAIL_OR_PASSWORD = 'MISSING_EMAIL_OR_PASSWORD'
|
||||||
|
export const INVALID_CREDENTIALS = 'INVALID_CREDENTIALS'
|
||||||
|
export const NOT_VERIFIED = 'NOT_VERIFIED'
|
||||||
18
web/vue-app/src/components/BackendEventsListener.vue
Normal file
18
web/vue-app/src/components/BackendEventsListener.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useBackendEvents } from '@/common/backendEvents'
|
||||||
|
import { currentUserId } from '@/stores/auth'
|
||||||
|
|
||||||
|
const userId = ref(currentUserId.value)
|
||||||
|
|
||||||
|
watch(currentUserId, (id) => {
|
||||||
|
userId.value = id
|
||||||
|
})
|
||||||
|
|
||||||
|
// Always call useBackendEvents in setup, passing the reactive userId
|
||||||
|
useBackendEvents(userId)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div></div>
|
||||||
|
</template>
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { eventBus } from '@/common/eventBus'
|
|
||||||
import { authenticateParent, isParentAuthenticated, logout } from '../stores/auth'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const show = ref(false)
|
|
||||||
const pin = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const pinInput = ref<HTMLInputElement | null>(null)
|
|
||||||
|
|
||||||
const open = async () => {
|
|
||||||
pin.value = ''
|
|
||||||
error.value = ''
|
|
||||||
show.value = true
|
|
||||||
await nextTick()
|
|
||||||
pinInput.value?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
show.value = false
|
|
||||||
error.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
const isDigits = /^\d{4,6}$/.test(pin.value)
|
|
||||||
if (!isDigits) {
|
|
||||||
error.value = 'Enter 4–6 digits'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pin.value !== '1179') {
|
|
||||||
error.value = 'Incorrect PIN'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticate parent and navigate
|
|
||||||
authenticateParent()
|
|
||||||
close()
|
|
||||||
router.push('/parent')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
logout()
|
|
||||||
router.push('/child')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
eventBus.on('open-login', open)
|
|
||||||
})
|
|
||||||
onUnmounted(() => {
|
|
||||||
eventBus.off('open-login', open)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="login-button-root">
|
|
||||||
<button v-if="!isParentAuthenticated" @click="open" aria-label="Parent login" class="login-btn">
|
|
||||||
Parent
|
|
||||||
</button>
|
|
||||||
<button v-else @click="handleLogout" aria-label="Parent logout" class="login-btn">
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-if="show" class="modal-backdrop" @click.self="close">
|
|
||||||
<div class="modal">
|
|
||||||
<h3>Enter parent PIN</h3>
|
|
||||||
<form @submit.prevent="submit">
|
|
||||||
<input
|
|
||||||
ref="pinInput"
|
|
||||||
v-model="pin"
|
|
||||||
inputmode="numeric"
|
|
||||||
pattern="\d*"
|
|
||||||
maxlength="6"
|
|
||||||
placeholder="4–6 digits"
|
|
||||||
class="pin-input"
|
|
||||||
/>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary">OK</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* modal */
|
|
||||||
|
|
||||||
.modal h3 {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pin-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e6e6e6;
|
|
||||||
margin-bottom: 0.6rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button-root {
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
51
web/vue-app/src/components/auth/AuthLanding.vue
Normal file
51
web/vue-app/src/components/auth/AuthLanding.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<div class="auth-landing">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1>Welcome</h1>
|
||||||
|
<p>Please sign in or create an account to continue.</p>
|
||||||
|
<div class="auth-actions">
|
||||||
|
<button class="btn btn-primary" @click="goToLogin">Log In</button>
|
||||||
|
<button class="btn btn-secondary" @click="goToSignup">Sign Up</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
const router = useRouter()
|
||||||
|
function goToLogin() {
|
||||||
|
router.push({ name: 'Login' })
|
||||||
|
}
|
||||||
|
function goToSignup() {
|
||||||
|
router.push({ name: 'Signup' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-landing {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--header-bg, linear-gradient(135deg, var(--primary), var(--secondary)));
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--card-shadow, 0 8px 32px rgba(0, 0, 0, 0.13));
|
||||||
|
text-align: center;
|
||||||
|
max-width: 340px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.auth-card h1 {
|
||||||
|
color: var(--card-title, #333);
|
||||||
|
}
|
||||||
|
.auth-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.2rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
187
web/vue-app/src/components/auth/ForgotPassword.vue
Normal file
187
web/vue-app/src/components/auth/ForgotPassword.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout">
|
||||||
|
<div class="edit-view">
|
||||||
|
<form class="forgot-form" @submit.prevent="submitForm" novalidate>
|
||||||
|
<h2>Reset your password</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email address</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="username"
|
||||||
|
autofocus
|
||||||
|
v-model="email"
|
||||||
|
:class="{ 'input-error': submitAttempted && !isEmailValid }"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small v-if="submitAttempted && !email" class="error-message" aria-live="polite">
|
||||||
|
Email is required.
|
||||||
|
</small>
|
||||||
|
<small
|
||||||
|
v-else-if="submitAttempted && !isEmailValid"
|
||||||
|
class="error-message"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
Please enter a valid email address.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMsg" class="error-message" style="margin-bottom: 1rem" aria-live="polite">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="successMsg"
|
||||||
|
class="success-message"
|
||||||
|
style="margin-bottom: 1rem"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{{ successMsg }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top: 0.4rem">
|
||||||
|
<button type="submit" class="form-btn" :disabled="loading || !isEmailValid">
|
||||||
|
{{ loading ? 'Sending…' : 'Send reset link' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
color: var(--sub-message-color, #6b7280);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Remembered your password?
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link"
|
||||||
|
@click="goToLogin"
|
||||||
|
style="
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--btn-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 6px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { isEmailValid } from '@/common/api'
|
||||||
|
import '@/assets/view-shared.css'
|
||||||
|
import '@/assets/global.css'
|
||||||
|
import '@/assets/edit-forms.css'
|
||||||
|
import '@/assets/actions-shared.css'
|
||||||
|
import '@/assets/button-shared.css'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const submitAttempted = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const successMsg = ref('')
|
||||||
|
|
||||||
|
const isEmailValidRef = computed(() => isEmailValid(email.value))
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
submitAttempted.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = ''
|
||||||
|
|
||||||
|
if (!isEmailValidRef.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/request-password-reset', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email.value.trim() }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = 'Could not send reset email.'
|
||||||
|
try {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data && (data.error || data.message)) {
|
||||||
|
msg = data.error || data.message
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const text = await res.text()
|
||||||
|
if (text) msg = text
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
errorMsg.value = msg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
successMsg.value =
|
||||||
|
'If this email is registered, you will receive a password reset link shortly.'
|
||||||
|
email.value = ''
|
||||||
|
submitAttempted.value = false
|
||||||
|
} catch {
|
||||||
|
errorMsg.value = 'Network error. Please try again.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToLogin() {
|
||||||
|
await router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.edit-view) {
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-form {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
color: var(--form-label, #444);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group input[type='email'] {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--form-input-bg, #fff);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:disabled {
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.forgot-form {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
326
web/vue-app/src/components/auth/Login.vue
Normal file
326
web/vue-app/src/components/auth/Login.vue
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout">
|
||||||
|
<div class="edit-view">
|
||||||
|
<form class="login-form" @submit.prevent="submitForm" novalidate>
|
||||||
|
<h2>Sign in</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email address</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="username"
|
||||||
|
autofocus
|
||||||
|
v-model="email"
|
||||||
|
:class="{ 'input-error': submitAttempted && !isEmailValid }"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small v-if="submitAttempted && !email" class="error-message" aria-live="polite"
|
||||||
|
>Email is required.</small
|
||||||
|
>
|
||||||
|
<small
|
||||||
|
v-else-if="submitAttempted && !isEmailValid"
|
||||||
|
class="error-message"
|
||||||
|
aria-live="polite"
|
||||||
|
>Please enter a valid email address.</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
v-model="password"
|
||||||
|
:class="{ 'input-error': submitAttempted && !password }"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small v-if="submitAttempted && !password" class="error-message" aria-live="polite"
|
||||||
|
>Password is required.</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- show server error message -->
|
||||||
|
<div v-if="loginError" class="error-message" style="margin-bottom: 1rem" aria-live="polite">
|
||||||
|
{{ loginError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- show resend UI when server indicated unverified account (independent of loginError) -->
|
||||||
|
<div v-if="showResend && !resendSent" style="margin-top: 0.5rem">
|
||||||
|
<button
|
||||||
|
v-if="!resendLoading"
|
||||||
|
type="button"
|
||||||
|
class="btn-link"
|
||||||
|
@click="resendVerification"
|
||||||
|
:disabled="!email"
|
||||||
|
>
|
||||||
|
Resend verification email
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span v-else class="btn-link btn-disabled" aria-busy="true">Sending…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- success / error messages for the resend action (shown even if loginError was cleared) -->
|
||||||
|
<div
|
||||||
|
v-if="resendSent"
|
||||||
|
style="margin-top: 0.5rem; color: var(--success, #16a34a); font-size: 0.92rem"
|
||||||
|
>
|
||||||
|
Verification email sent. Check your inbox.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="resendError" class="error-message" style="margin-top: 0.5rem" aria-live="polite">
|
||||||
|
{{ resendError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top: 0.4rem">
|
||||||
|
<button type="submit" class="form-btn" :disabled="loading || !formValid">
|
||||||
|
{{ loading ? 'Signing in…' : 'Sign in' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
color: var(--sub-message-color, #6b7280);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Don't have an account?
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link"
|
||||||
|
@click="goToSignup"
|
||||||
|
style="
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--btn-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 6px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
color: var(--sub-message-color, #6b7280);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link"
|
||||||
|
@click="goToForgotPassword"
|
||||||
|
style="
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--btn-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 6px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import '@/assets/view-shared.css'
|
||||||
|
import '@/assets/global.css'
|
||||||
|
import '@/assets/edit-forms.css'
|
||||||
|
import '@/assets/actions-shared.css'
|
||||||
|
import '@/assets/button-shared.css'
|
||||||
|
import {
|
||||||
|
MISSING_EMAIL_OR_PASSWORD,
|
||||||
|
INVALID_CREDENTIALS,
|
||||||
|
NOT_VERIFIED,
|
||||||
|
MISSING_EMAIL,
|
||||||
|
USER_NOT_FOUND,
|
||||||
|
ALREADY_VERIFIED,
|
||||||
|
} from '@/common/errorCodes'
|
||||||
|
import { parseErrorResponse, isEmailValid } from '@/common/api'
|
||||||
|
import { loginUser } from '@/stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const submitAttempted = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loginError = ref('')
|
||||||
|
|
||||||
|
/* new state for resend flow */
|
||||||
|
const showResend = ref(false)
|
||||||
|
const resendLoading = ref(false)
|
||||||
|
const resendSent = ref(false)
|
||||||
|
const resendError = ref('')
|
||||||
|
|
||||||
|
const isEmailValidRef = computed(() => isEmailValid(email.value))
|
||||||
|
const formValid = computed(() => email.value && isEmailValidRef.value && password.value)
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
submitAttempted.value = true
|
||||||
|
loginError.value = ''
|
||||||
|
showResend.value = false
|
||||||
|
resendError.value = ''
|
||||||
|
resendSent.value = false
|
||||||
|
|
||||||
|
if (!formValid.value) return
|
||||||
|
if (loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email.value.trim(), password: password.value }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const { msg, code } = await parseErrorResponse(res)
|
||||||
|
showResend.value = false
|
||||||
|
let displayMsg = msg
|
||||||
|
switch (code) {
|
||||||
|
case MISSING_EMAIL_OR_PASSWORD:
|
||||||
|
displayMsg = 'Email and password are required.'
|
||||||
|
break
|
||||||
|
case INVALID_CREDENTIALS:
|
||||||
|
displayMsg = 'The email and password combination is incorrect. Please try again.'
|
||||||
|
break
|
||||||
|
case NOT_VERIFIED:
|
||||||
|
displayMsg =
|
||||||
|
'Your account is not verified. Please check your email for the verification link.'
|
||||||
|
showResend.value = true
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
displayMsg = msg || `Login failed with status ${res.status}.`
|
||||||
|
}
|
||||||
|
loginError.value = displayMsg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loginUser() // <-- set user as logged in
|
||||||
|
|
||||||
|
await router.push({ path: '/' }).catch(() => (window.location.href = '/'))
|
||||||
|
} catch (err) {
|
||||||
|
loginError.value = 'Network error. Please try again.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resendVerification() {
|
||||||
|
loginError.value = ''
|
||||||
|
resendError.value = ''
|
||||||
|
resendSent.value = false
|
||||||
|
if (!email.value) {
|
||||||
|
resendError.value = 'Please enter your email above to resend verification.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resendLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/resend-verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email.value }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const { msg, code } = await parseErrorResponse(res)
|
||||||
|
resendError.value = msg
|
||||||
|
let displayMsg = msg
|
||||||
|
switch (code) {
|
||||||
|
case MISSING_EMAIL:
|
||||||
|
displayMsg = 'Email is required.'
|
||||||
|
break
|
||||||
|
case USER_NOT_FOUND:
|
||||||
|
displayMsg = 'This email is not registered.'
|
||||||
|
break
|
||||||
|
case ALREADY_VERIFIED:
|
||||||
|
displayMsg = 'Your account is already verified. Please log in.'
|
||||||
|
showResend.value = false
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
displayMsg = msg || `Login failed with status ${res.status}.`
|
||||||
|
}
|
||||||
|
resendError.value = displayMsg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resendSent.value = true
|
||||||
|
} catch {
|
||||||
|
resendError.value = 'Network error. Please try again.'
|
||||||
|
} finally {
|
||||||
|
resendLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToSignup() {
|
||||||
|
await router.push({ name: 'Signup' }).catch(() => (window.location.href = '/auth/signup'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToForgotPassword() {
|
||||||
|
await router
|
||||||
|
.push({ name: 'ForgotPassword' })
|
||||||
|
.catch(() => (window.location.href = '/auth/forgot-password'))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.edit-view) {
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* reuse edit-forms form-group styles */
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
color: var(--form-label, #444);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group input[type='email'],
|
||||||
|
.form-group input[type='password'] {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--form-input-bg, #fff);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* also ensure disabled button doesn't show underline in browsers that style disabled anchors/buttons */
|
||||||
|
.btn-link:disabled {
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.login-form {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
286
web/vue-app/src/components/auth/ResetPassword.vue
Normal file
286
web/vue-app/src/components/auth/ResetPassword.vue
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout">
|
||||||
|
<div class="edit-view">
|
||||||
|
<form
|
||||||
|
v-if="tokenChecked && tokenValid"
|
||||||
|
class="reset-form"
|
||||||
|
@submit.prevent="submitForm"
|
||||||
|
novalidate
|
||||||
|
>
|
||||||
|
<h2>Set a new password</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">New password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
v-model="password"
|
||||||
|
:class="{ 'input-error': submitAttempted && !isPasswordStrong }"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small v-if="submitAttempted && !password" class="error-message" aria-live="polite">
|
||||||
|
Password is required.
|
||||||
|
</small>
|
||||||
|
<small
|
||||||
|
v-else-if="submitAttempted && !isPasswordStrong"
|
||||||
|
class="error-message"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
Password must be at least 8 characters and contain a letter and a number.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirm">Confirm password</label>
|
||||||
|
<input
|
||||||
|
id="confirm"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
:class="{ 'input-error': submitAttempted && !passwordsMatch }"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small
|
||||||
|
v-if="submitAttempted && !confirmPassword"
|
||||||
|
class="error-message"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
Please confirm your password.
|
||||||
|
</small>
|
||||||
|
<small
|
||||||
|
v-else-if="submitAttempted && !passwordsMatch"
|
||||||
|
class="error-message"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
Passwords do not match.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMsg" class="error-message" style="margin-bottom: 1rem" aria-live="polite">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="successMsg"
|
||||||
|
class="success-message"
|
||||||
|
style="margin-bottom: 1rem"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{{ successMsg }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top: 0.4rem">
|
||||||
|
<button type="submit" class="form-btn" :disabled="loading || !formValid">
|
||||||
|
{{ loading ? 'Resetting…' : 'Reset password' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
color: var(--sub-message-color, #6b7280);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Remembered your password?
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link"
|
||||||
|
@click="goToLogin"
|
||||||
|
style="
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--btn-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 6px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
<div
|
||||||
|
v-else-if="tokenChecked && !tokenValid"
|
||||||
|
class="error-message"
|
||||||
|
aria-live="polite"
|
||||||
|
style="margin-top: 2rem"
|
||||||
|
>
|
||||||
|
{{ errorMsg }}
|
||||||
|
<div style="margin-top: 1.2rem">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link"
|
||||||
|
@click="goToLogin"
|
||||||
|
style="
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--btn-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 6px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else style="margin-top: 2rem; text-align: center">Checking reset link...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { isPasswordStrong } from '@/common/api'
|
||||||
|
import '@/assets/view-shared.css'
|
||||||
|
import '@/assets/global.css'
|
||||||
|
import '@/assets/edit-forms.css'
|
||||||
|
import '@/assets/actions-shared.css'
|
||||||
|
import '@/assets/button-shared.css'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const submitAttempted = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const successMsg = ref('')
|
||||||
|
const token = ref('')
|
||||||
|
const tokenValid = ref(false)
|
||||||
|
const tokenChecked = ref(false)
|
||||||
|
|
||||||
|
const isPasswordStrongRef = computed(() => isPasswordStrong(password.value))
|
||||||
|
const passwordsMatch = computed(() => password.value === confirmPassword.value)
|
||||||
|
const formValid = computed(
|
||||||
|
() =>
|
||||||
|
password.value && confirmPassword.value && isPasswordStrongRef.value && passwordsMatch.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Get token from query string
|
||||||
|
const raw = route.query.token ?? ''
|
||||||
|
token.value = Array.isArray(raw) ? raw[0] : String(raw || '')
|
||||||
|
|
||||||
|
// Validate token with backend
|
||||||
|
if (token.value) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/validate-reset-token?token=${encodeURIComponent(token.value)}`)
|
||||||
|
tokenChecked.value = true
|
||||||
|
if (res.ok) {
|
||||||
|
tokenValid.value = true
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
errorMsg.value = data.error || 'This password reset link is invalid or has expired.'
|
||||||
|
tokenValid.value = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMsg.value = 'Network error. Please try again.'
|
||||||
|
tokenValid.value = false
|
||||||
|
tokenChecked.value = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorMsg.value = 'No reset token provided.'
|
||||||
|
tokenValid.value = false
|
||||||
|
tokenChecked.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
submitAttempted.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = ''
|
||||||
|
|
||||||
|
if (!formValid.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: token.value,
|
||||||
|
password: password.value,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = 'Could not reset password.'
|
||||||
|
try {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data && (data.error || data.message)) {
|
||||||
|
msg = data.error || data.message
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const text = await res.text()
|
||||||
|
if (text) msg = text
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
errorMsg.value = msg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
successMsg.value = 'Your password has been reset. You may now sign in.'
|
||||||
|
password.value = ''
|
||||||
|
confirmPassword.value = ''
|
||||||
|
submitAttempted.value = false // <-- add this line
|
||||||
|
} catch {
|
||||||
|
errorMsg.value = 'Network error. Please try again.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToLogin() {
|
||||||
|
await router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.edit-view) {
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-form {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
color: var(--form-label, #444);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group input[type='password'] {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--form-input-bg, #fff);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:disabled {
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.reset-form {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
369
web/vue-app/src/components/auth/Signup.vue
Normal file
369
web/vue-app/src/components/auth/Signup.vue
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout">
|
||||||
|
<div class="edit-view">
|
||||||
|
<form
|
||||||
|
v-if="!signupSuccess"
|
||||||
|
@submit.prevent="submitForm"
|
||||||
|
class="signup-form child-edit-view"
|
||||||
|
novalidate
|
||||||
|
>
|
||||||
|
<h2>Sign up</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="firstName">First name</label>
|
||||||
|
<input
|
||||||
|
v-model="firstName"
|
||||||
|
id="firstName"
|
||||||
|
type="text"
|
||||||
|
autofocus
|
||||||
|
autocomplete="given-name"
|
||||||
|
required
|
||||||
|
:class="{ 'input-error': submitAttempted && !firstName }"
|
||||||
|
/>
|
||||||
|
<small v-if="submitAttempted && !firstName" class="error-message" aria-live="polite"
|
||||||
|
>First name is required.</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="lastName">Last name</label>
|
||||||
|
<input
|
||||||
|
v-model="lastName"
|
||||||
|
id="lastName"
|
||||||
|
autocomplete="family-name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
:class="{ 'input-error': submitAttempted && !lastName }"
|
||||||
|
/>
|
||||||
|
<small v-if="submitAttempted && !lastName" class="error-message" aria-live="polite"
|
||||||
|
>Last name is required.</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email address</label>
|
||||||
|
<input
|
||||||
|
v-model="email"
|
||||||
|
id="email"
|
||||||
|
autocomplete="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
:class="{ 'input-error': submitAttempted && (!email || !isEmailValid) }"
|
||||||
|
/>
|
||||||
|
<small v-if="submitAttempted && !email" class="error-message" aria-live="polite"
|
||||||
|
>Email is required.</small
|
||||||
|
>
|
||||||
|
<small v-else-if="submitAttempted && !isEmailValid" class="error-message"
|
||||||
|
>Please enter a valid email address.</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
id="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
@input="checkPasswordStrength"
|
||||||
|
:class="{ 'input-error': (submitAttempted || passwordTouched) && !isPasswordStrong }"
|
||||||
|
/>
|
||||||
|
<small
|
||||||
|
v-if="(submitAttempted || passwordTouched) && !isPasswordStrong"
|
||||||
|
class="error-message"
|
||||||
|
aria-live="polite"
|
||||||
|
>Password must be at least 8 characters, include a number and a letter.</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirmPassword">Confirm password</label>
|
||||||
|
<input
|
||||||
|
v-model="confirmPassword"
|
||||||
|
id="confirmPassword"
|
||||||
|
autocomplete="new-password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
:class="{ 'input-error': (submitAttempted || confirmTouched) && !passwordsMatch }"
|
||||||
|
@blur="confirmTouched = true"
|
||||||
|
/>
|
||||||
|
<small
|
||||||
|
v-if="(submitAttempted || confirmTouched) && !passwordsMatch"
|
||||||
|
class="error-message"
|
||||||
|
aria-live="polite"
|
||||||
|
>Passwords do not match.</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top: 0.4rem">
|
||||||
|
<button type="submit" class="form-btn" :disabled="!formValid || loading">Sign up</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Error and success messages -->
|
||||||
|
<ErrorMessage v-if="signupError" :message="signupError" aria-live="polite" />
|
||||||
|
|
||||||
|
<!-- Modal for "Account already exists" -->
|
||||||
|
<ModalDialog v-if="showEmailExistsModal">
|
||||||
|
<h3>Account already exists</h3>
|
||||||
|
<p>
|
||||||
|
An account with <strong>{{ email }}</strong> already exists.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 2rem; justify-content: center">
|
||||||
|
<button @click="goToLogin" class="form-btn">Sign In</button>
|
||||||
|
<button @click="showEmailExistsModal = false" class="form-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
|
||||||
|
<!-- Verification card shown after successful signup -->
|
||||||
|
<div v-else-if="signupSuccess">
|
||||||
|
<div class="icon-wrap" aria-hidden="true">
|
||||||
|
<!-- simple check icon -->
|
||||||
|
<svg
|
||||||
|
class="success-icon"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M20 6L9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="card-title">Check your email</h2>
|
||||||
|
<p class="card-message">
|
||||||
|
A verification link has been sent to <strong>{{ email }}</strong
|
||||||
|
>. Please open the email and follow the instructions to verify your account.
|
||||||
|
</p>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="form-btn" @click="goToLogin">Go to Sign In</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ErrorMessage from '@/components/shared/ErrorMessage.vue'
|
||||||
|
import ModalDialog from '@/components/shared/ModalDialog.vue'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { isEmailValid, isPasswordStrong } from '@/common/api'
|
||||||
|
import { EMAIL_EXISTS, MISSING_FIELDS } from '@/common/errorCodes'
|
||||||
|
import '@/assets/view-shared.css'
|
||||||
|
import '@/assets/actions-shared.css'
|
||||||
|
import '@/assets/button-shared.css'
|
||||||
|
import '@/assets/global.css'
|
||||||
|
import '@/assets/edit-forms.css'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const firstName = ref('')
|
||||||
|
const lastName = ref('')
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const passwordTouched = ref(false)
|
||||||
|
const confirmTouched = ref(false)
|
||||||
|
const submitAttempted = ref(false)
|
||||||
|
const signupError = ref('')
|
||||||
|
const signupSuccess = ref(false)
|
||||||
|
const showEmailExistsModal = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
function checkPasswordStrength() {
|
||||||
|
passwordTouched.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmailValidRef = computed(() => isEmailValid(email.value))
|
||||||
|
const isPasswordStrongRef = computed(() => isPasswordStrong(password.value))
|
||||||
|
|
||||||
|
const passwordsMatch = computed(() => password.value === confirmPassword.value)
|
||||||
|
|
||||||
|
const formValid = computed(
|
||||||
|
() =>
|
||||||
|
firstName.value &&
|
||||||
|
lastName.value &&
|
||||||
|
email.value &&
|
||||||
|
isEmailValidRef.value &&
|
||||||
|
isPasswordStrongRef.value &&
|
||||||
|
passwordsMatch.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
submitAttempted.value = true
|
||||||
|
passwordTouched.value = true
|
||||||
|
confirmTouched.value = true
|
||||||
|
signupError.value = ''
|
||||||
|
signupSuccess.value = false
|
||||||
|
showEmailExistsModal.value = false
|
||||||
|
if (!formValid.value) return
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const response = await fetch('/api/signup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
first_name: firstName.value.trim(),
|
||||||
|
last_name: lastName.value.trim(),
|
||||||
|
email: email.value.trim(),
|
||||||
|
password: password.value,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const { msg, code } = await parseErrorResponse(response)
|
||||||
|
let displayMsg = msg
|
||||||
|
switch (code) {
|
||||||
|
case MISSING_FIELDS:
|
||||||
|
displayMsg = 'Please fill in all required fields.'
|
||||||
|
clearFields()
|
||||||
|
break
|
||||||
|
case EMAIL_EXISTS:
|
||||||
|
displayMsg = 'An account with this email already exists.'
|
||||||
|
showEmailExistsModal.value = true
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
signupError.value = displayMsg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Signup successful
|
||||||
|
signupSuccess.value = true
|
||||||
|
clearFields()
|
||||||
|
} catch (err) {
|
||||||
|
signupError.value = 'Network error. Please try again.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToLogin() {
|
||||||
|
router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear password fields and close modal
|
||||||
|
function handleCancelEmailExists() {
|
||||||
|
password.value = ''
|
||||||
|
confirmPassword.value = ''
|
||||||
|
showEmailExistsModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseErrorResponse(res: Response): Promise<{ msg: string; code?: string }> {
|
||||||
|
try {
|
||||||
|
const data = await res.json()
|
||||||
|
return { msg: data.error || data.message || 'Signup failed.', code: data.code }
|
||||||
|
} catch {
|
||||||
|
const text = await res.text()
|
||||||
|
return { msg: text || 'Signup failed.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFields() {
|
||||||
|
firstName.value = ''
|
||||||
|
lastName.value = ''
|
||||||
|
email.value = ''
|
||||||
|
password.value = ''
|
||||||
|
confirmPassword.value = ''
|
||||||
|
passwordTouched.value = false
|
||||||
|
confirmTouched.value = false
|
||||||
|
submitAttempted.value = false
|
||||||
|
signupError.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.edit-view) {
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-form {
|
||||||
|
/* keep the edit-view / child-edit-view look from edit-forms.css,
|
||||||
|
only adjust inputs for email/password types */
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrap {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, var(--btn-green, #22c55e), var(--btn-green-hover, #16a34a));
|
||||||
|
box-shadow: 0 8px 20px rgba(34, 197, 94, 0.12);
|
||||||
|
}
|
||||||
|
.success-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
stroke: #fff;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--card-title, #333);
|
||||||
|
}
|
||||||
|
.card-message {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
color: var(--sub-message-color, #6b7280);
|
||||||
|
font-size: 0.98rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.card-actions {
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reuse existing input / label styles */
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
color: var(--form-label, #444);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group input[type='email'],
|
||||||
|
.form-group input[type='password'] {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--form-input-bg, #fff);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styles */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal-dialog {
|
||||||
|
background: #fff;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||||
|
max-width: 340px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
316
web/vue-app/src/components/auth/VerifySignup.vue
Normal file
316
web/vue-app/src/components/auth/VerifySignup.vue
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout">
|
||||||
|
<div class="edit-view">
|
||||||
|
<div class="verify-container">
|
||||||
|
<h2 v-if="verifyingLoading">Verifying…</h2>
|
||||||
|
|
||||||
|
<div v-if="verified" class="success-message" aria-live="polite">
|
||||||
|
Your account has been verified.
|
||||||
|
<div class="meta">
|
||||||
|
Redirecting to sign in in <strong>{{ countdown }}</strong> second<span
|
||||||
|
v-if="countdown !== 1"
|
||||||
|
>s</span
|
||||||
|
>.
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 0.6rem">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link"
|
||||||
|
@click="goToLogin"
|
||||||
|
style="
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--btn-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 6px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- Error or success message at the top -->
|
||||||
|
<div
|
||||||
|
v-if="verifyError"
|
||||||
|
class="error-message"
|
||||||
|
aria-live="polite"
|
||||||
|
style="margin-bottom: 1rem"
|
||||||
|
>
|
||||||
|
{{ verifyError }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="resendSuccess"
|
||||||
|
class="success-message"
|
||||||
|
aria-live="polite"
|
||||||
|
style="margin-bottom: 1rem"
|
||||||
|
>
|
||||||
|
Verification email sent. Check your inbox.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email form and resend button -->
|
||||||
|
<form @submit.prevent="handleResend" v-if="!sendingDialog">
|
||||||
|
<label
|
||||||
|
for="resend-email"
|
||||||
|
style="display: block; font-weight: 600; margin-bottom: 0.25rem"
|
||||||
|
>Email address</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="resend-email"
|
||||||
|
v-model.trim="resendEmail"
|
||||||
|
autofocus
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
:class="{ 'input-error': resendAttempted && !isResendEmailValid }"
|
||||||
|
/>
|
||||||
|
<small v-if="resendAttempted && !resendEmail" class="error-message" aria-live="polite"
|
||||||
|
>Email is required.</small
|
||||||
|
>
|
||||||
|
<small
|
||||||
|
v-else-if="resendAttempted && !isResendEmailValid"
|
||||||
|
class="error-message"
|
||||||
|
aria-live="polite"
|
||||||
|
>Please enter a valid email address.</small
|
||||||
|
>
|
||||||
|
<div style="margin-top: 0.6rem">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="form-btn"
|
||||||
|
:disabled="!isResendEmailValid || resendLoading"
|
||||||
|
>
|
||||||
|
Resend verification email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Sending dialog -->
|
||||||
|
<div v-if="sendingDialog" class="sending-dialog">
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
style="
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="modal-dialog"
|
||||||
|
style="
|
||||||
|
background: #fff;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||||
|
max-width: 340px;
|
||||||
|
text-align: center;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<h3 style="margin-bottom: 1rem">Sending Verification Email…</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 0.8rem">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link"
|
||||||
|
@click="goToLogin"
|
||||||
|
style="
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--btn-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 6px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import {
|
||||||
|
MISSING_TOKEN,
|
||||||
|
TOKEN_TIMESTAMP_MISSING,
|
||||||
|
TOKEN_EXPIRED,
|
||||||
|
INVALID_TOKEN,
|
||||||
|
MISSING_EMAIL,
|
||||||
|
USER_NOT_FOUND,
|
||||||
|
ALREADY_VERIFIED,
|
||||||
|
} from '@/common/errorCodes'
|
||||||
|
import '@/assets/actions-shared.css'
|
||||||
|
import '@/assets/button-shared.css'
|
||||||
|
import { parseErrorResponse } from '@/common/api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const verifyingLoading = ref(true)
|
||||||
|
const verified = ref(false)
|
||||||
|
const verifyError = ref('')
|
||||||
|
const resendSuccess = ref(false)
|
||||||
|
|
||||||
|
const countdown = ref(10)
|
||||||
|
let countdownTimer: number | null = null
|
||||||
|
|
||||||
|
// Resend state
|
||||||
|
const resendEmail = ref<string>((route.query.email as string) ?? '')
|
||||||
|
const resendAttempted = ref(false)
|
||||||
|
const resendLoading = ref(false)
|
||||||
|
const sendingDialog = ref(false)
|
||||||
|
|
||||||
|
const isResendEmailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(resendEmail.value))
|
||||||
|
|
||||||
|
async function verifyToken() {
|
||||||
|
const raw = route.query.token ?? ''
|
||||||
|
const token = Array.isArray(raw) ? raw[0] : String(raw || '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyingLoading.value = true
|
||||||
|
try {
|
||||||
|
const url = `/api/verify?token=${encodeURIComponent(token)}`
|
||||||
|
const res = await fetch(url, { method: 'GET' })
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const { msg, code } = await parseErrorResponse(res)
|
||||||
|
switch (code) {
|
||||||
|
case INVALID_TOKEN:
|
||||||
|
case MISSING_TOKEN:
|
||||||
|
case TOKEN_TIMESTAMP_MISSING:
|
||||||
|
verifyError.value =
|
||||||
|
"Your account isn't verified. Please request a new verification email."
|
||||||
|
break
|
||||||
|
case TOKEN_EXPIRED:
|
||||||
|
verifyError.value =
|
||||||
|
'Your verification link has expired. Please request a new verification email.'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
verifyError.value = msg || `Verification failed with status ${res.status}.`
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// success
|
||||||
|
verified.value = true
|
||||||
|
startRedirectCountdown()
|
||||||
|
} catch {
|
||||||
|
verifyError.value = 'Network error. Please try again.'
|
||||||
|
} finally {
|
||||||
|
verifyingLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRedirectCountdown() {
|
||||||
|
countdown.value = 10
|
||||||
|
countdownTimer = window.setInterval(() => {
|
||||||
|
countdown.value -= 1
|
||||||
|
if (countdown.value <= 0) {
|
||||||
|
clearCountdown()
|
||||||
|
router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCountdown() {
|
||||||
|
if (countdownTimer !== null) {
|
||||||
|
clearInterval(countdownTimer)
|
||||||
|
countdownTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearCountdown()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
verifyToken()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleResend() {
|
||||||
|
resendAttempted.value = true
|
||||||
|
resendSuccess.value = false
|
||||||
|
verifyError.value = ''
|
||||||
|
if (!isResendEmailValid.value) return
|
||||||
|
|
||||||
|
sendingDialog.value = true
|
||||||
|
resendLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/resend-verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: resendEmail.value.trim() }),
|
||||||
|
})
|
||||||
|
resendEmail.value = ''
|
||||||
|
sendingDialog.value = false
|
||||||
|
resendLoading.value = false
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const { msg, code } = await parseErrorResponse(res)
|
||||||
|
switch (code) {
|
||||||
|
case MISSING_EMAIL:
|
||||||
|
verifyError.value = 'An email address is required.'
|
||||||
|
break
|
||||||
|
case USER_NOT_FOUND:
|
||||||
|
verifyError.value = 'This email is not registered.'
|
||||||
|
break
|
||||||
|
case ALREADY_VERIFIED:
|
||||||
|
verifyError.value = 'Your account is already verified. Please log in.'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
verifyError.value = msg || `Resend failed with status ${res.status}.`
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resendSuccess.value = true
|
||||||
|
verifyError.value = ''
|
||||||
|
} catch {
|
||||||
|
verifyError.value = 'Network error. Please try again.'
|
||||||
|
} finally {
|
||||||
|
sendingDialog.value = false
|
||||||
|
resendLoading.value = false
|
||||||
|
resendAttempted.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToLogin() {
|
||||||
|
router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.edit-view) {
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-container {
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: var(--sub-message-color, #6b7280);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,16 +2,16 @@
|
|||||||
<div class="child-edit-view">
|
<div class="child-edit-view">
|
||||||
<h2>{{ isEdit ? 'Edit Child' : 'Create Child' }}</h2>
|
<h2>{{ isEdit ? 'Edit Child' : 'Create Child' }}</h2>
|
||||||
<div v-if="loading" class="loading-message">Loading child...</div>
|
<div v-if="loading" class="loading-message">Loading child...</div>
|
||||||
<form v-else @submit.prevent="submit" class="form">
|
<form v-else @submit.prevent="submit" class="child-edit-form">
|
||||||
<div class="form-group">
|
<div class="group">
|
||||||
<label for="child-name">Name</label>
|
<label for="child-name">Name</label>
|
||||||
<input id="child-name" ref="nameInput" v-model="name" required maxlength="64" />
|
<input type="text" id="child-name" ref="nameInput" v-model="name" required maxlength="64" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="group">
|
||||||
<label for="child-age">Age</label>
|
<label for="child-age">Age</label>
|
||||||
<input id="child-age" v-model.number="age" type="number" min="0" max="120" required />
|
<input id="child-age" v-model.number="age" type="number" min="0" max="120" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group image-picker-group">
|
<div class="group">
|
||||||
<label for="child-image">Image</label>
|
<label for="child-image">Image</label>
|
||||||
<ImagePicker
|
<ImagePicker
|
||||||
id="child-image"
|
id="child-image"
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import ImagePicker from '../ImagePicker.vue'
|
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||||
import '@/assets/edit-forms.css'
|
import '@/assets/edit-forms.css'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -154,44 +154,4 @@ function onCancel() {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
.form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.1rem;
|
|
||||||
}
|
|
||||||
.form-group {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.35rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--form-label-color);
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
input[type='text'],
|
|
||||||
input[type='number'] {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0.5rem 0.7rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--form-input-border);
|
|
||||||
font-size: 1rem;
|
|
||||||
background: var(--form-input-bg);
|
|
||||||
color: var(--form-input-color);
|
|
||||||
transition: border 0.2s;
|
|
||||||
}
|
|
||||||
input:focus {
|
|
||||||
outline: none;
|
|
||||||
border: 1.5px solid var(--form-input-focus);
|
|
||||||
}
|
|
||||||
.form-group.image-picker-group {
|
|
||||||
display: block;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
169
web/vue-app/src/components/profile/UserProfile.vue
Normal file
169
web/vue-app/src/components/profile/UserProfile.vue
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div class="profile-view">
|
||||||
|
<h2>User Profile</h2>
|
||||||
|
<form class="profile-form" @submit.prevent>
|
||||||
|
<div class="group">
|
||||||
|
<label for="child-image">Image</label>
|
||||||
|
<ImagePicker
|
||||||
|
id="child-image"
|
||||||
|
v-model="selectedImageId"
|
||||||
|
:image-type="1"
|
||||||
|
@add-image="onAddImage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<label for="first-name">First Name</label>
|
||||||
|
<input id="first-name" v-model="firstName" type="text" disabled />
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<label for="last-name">Last Name</label>
|
||||||
|
<input id="last-name" v-model="lastName" type="text" disabled />
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<label for="email">Email Address</label>
|
||||||
|
<input id="email" v-model="email" type="email" disabled />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn-link" @click="resetPassword" :disabled="resetting">
|
||||||
|
{{ resetting ? 'Sending...' : 'Reset Password' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="successMsg" class="success-message" aria-live="polite">{{ successMsg }}</div>
|
||||||
|
<div v-if="errorMsg" class="error-message" aria-live="polite">{{ errorMsg }}</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||||
|
import { getCachedImageUrl } from '@/common/imageCache'
|
||||||
|
import '@/assets/edit-forms.css'
|
||||||
|
import '@/assets/actions-shared.css'
|
||||||
|
import '@/assets/button-shared.css'
|
||||||
|
|
||||||
|
const firstName = ref('')
|
||||||
|
const lastName = ref('')
|
||||||
|
const email = ref('')
|
||||||
|
const avatarId = ref<string | null>(null)
|
||||||
|
const avatarUrl = ref('/static/avatar-default.png')
|
||||||
|
const selectedImageId = ref<string | null>(null)
|
||||||
|
const localImageFile = ref<File | null>(null)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const successMsg = ref('')
|
||||||
|
const resetting = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/profile')
|
||||||
|
if (!res.ok) throw new Error('Failed to load profile')
|
||||||
|
const data = await res.json()
|
||||||
|
firstName.value = data.first_name || ''
|
||||||
|
lastName.value = data.last_name || ''
|
||||||
|
email.value = data.email || ''
|
||||||
|
avatarId.value = data.image_id || null
|
||||||
|
selectedImageId.value = data.image_id || null
|
||||||
|
|
||||||
|
// Use imageCache to get avatar URL
|
||||||
|
if (avatarId.value) {
|
||||||
|
avatarUrl.value = await getCachedImageUrl(avatarId.value)
|
||||||
|
} else {
|
||||||
|
avatarUrl.value = '/static/avatar-default.png'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMsg.value = 'Could not load user profile.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for avatarId changes (e.g., after updating avatar)
|
||||||
|
watch(avatarId, async (id) => {
|
||||||
|
if (id) {
|
||||||
|
avatarUrl.value = await getCachedImageUrl(id)
|
||||||
|
} else {
|
||||||
|
avatarUrl.value = '/static/avatar-default.png'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||||
|
if (id === 'local-upload') {
|
||||||
|
localImageFile.value = file
|
||||||
|
} else {
|
||||||
|
localImageFile.value = null
|
||||||
|
selectedImageId.value = id
|
||||||
|
updateAvatar(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAvatar(imageId: string) {
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = ''
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/avatar', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ image_id: imageId }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to update avatar')
|
||||||
|
// Update avatarId, which will trigger the watcher to update avatarUrl
|
||||||
|
avatarId.value = imageId
|
||||||
|
successMsg.value = 'Avatar updated!'
|
||||||
|
} catch {
|
||||||
|
errorMsg.value = 'Failed to update avatar.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If uploading a new image file
|
||||||
|
watch(localImageFile, async (file) => {
|
||||||
|
if (!file) return
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = ''
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('type', '2')
|
||||||
|
formData.append('permanent', 'true')
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/image/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error('Image upload failed')
|
||||||
|
const data = await resp.json()
|
||||||
|
selectedImageId.value = data.id
|
||||||
|
await updateAvatar(data.id)
|
||||||
|
} catch {
|
||||||
|
errorMsg.value = 'Failed to upload avatar image.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function resetPassword() {
|
||||||
|
resetting.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = ''
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/request-password-reset', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email.value }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to send reset email')
|
||||||
|
successMsg.value =
|
||||||
|
'If this email is registered, you will receive a password reset link shortly.'
|
||||||
|
} catch {
|
||||||
|
errorMsg.value = 'Failed to send password reset email.'
|
||||||
|
} finally {
|
||||||
|
resetting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.success-message {
|
||||||
|
color: var(--success, #16a34a);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.error-message {
|
||||||
|
color: var(--error, #e53e3e);
|
||||||
|
font-size: 0.98rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,19 +3,25 @@
|
|||||||
<h2>{{ isEdit ? 'Edit Reward' : 'Create Reward' }}</h2>
|
<h2>{{ isEdit ? 'Edit Reward' : 'Create Reward' }}</h2>
|
||||||
<div v-if="loading" class="loading-message">Loading reward...</div>
|
<div v-if="loading" class="loading-message">Loading reward...</div>
|
||||||
<form v-else @submit.prevent="submit" class="reward-form">
|
<form v-else @submit.prevent="submit" class="reward-form">
|
||||||
|
<div class="group">
|
||||||
<label>
|
<label>
|
||||||
Reward Name
|
Reward Name
|
||||||
<input ref="nameInput" v-model="name" type="text" required maxlength="64" />
|
<input ref="nameInput" v-model="name" type="text" required maxlength="64" />
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
<label>
|
<label>
|
||||||
Description
|
Description
|
||||||
<input v-model="description" type="text" maxlength="128" />
|
<input v-model="description" type="text" maxlength="128" />
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
<label>
|
<label>
|
||||||
Cost
|
Cost
|
||||||
<input v-model.number="cost" type="number" min="1" max="1000" required />
|
<input v-model.number="cost" type="number" min="1" max="1000" required />
|
||||||
</label>
|
</label>
|
||||||
<div class="form-group image-picker-group">
|
</div>
|
||||||
|
<div class="group">
|
||||||
<label for="reward-image">Image</label>
|
<label for="reward-image">Image</label>
|
||||||
<ImagePicker
|
<ImagePicker
|
||||||
id="reward-image"
|
id="reward-image"
|
||||||
@@ -40,7 +46,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import ImagePicker from '../ImagePicker.vue'
|
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||||
import '@/assets/edit-forms.css'
|
import '@/assets/edit-forms.css'
|
||||||
|
|
||||||
const props = defineProps<{ id?: string }>()
|
const props = defineProps<{ id?: string }>()
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache'
|
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||||
import { isParentAuthenticated } from '../stores/auth'
|
import { isParentAuthenticated } from '../../stores/auth'
|
||||||
import { eventBus } from '@/common/eventBus'
|
import { eventBus } from '@/common/eventBus'
|
||||||
import type {
|
import type {
|
||||||
Child,
|
Child,
|
||||||
@@ -11,6 +11,10 @@ import type {
|
|||||||
ChildRewardTriggeredEventPayload,
|
ChildRewardTriggeredEventPayload,
|
||||||
Event,
|
Event,
|
||||||
} from '@/common/models'
|
} from '@/common/models'
|
||||||
|
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||||
|
import '@/assets/common.css'
|
||||||
|
import '@/assets/modal.css'
|
||||||
|
import '@/assets/button-shared.css'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const children = ref<Child[]>([])
|
const children = ref<Child[]>([])
|
||||||
@@ -273,16 +277,13 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="children.length === 0" class="no-message">
|
<MessageBlock v-if="children.length === 0" message="No children">
|
||||||
<div>No children</div>
|
<span v-if="!isParentAuthenticated">
|
||||||
<div class="sub-message">
|
<button class="round-btn" @click="eventBus.emit('open-login')">Sign in</button> to create a
|
||||||
<template v-if="!isParentAuthenticated">
|
child
|
||||||
<button class="sign-in-btn" @click="eventBus.emit('open-login')">Sign in</button> to
|
</span>
|
||||||
create a child
|
<span v-else> <button class="round-btn" @click="createChild">Create</button> a child </span>
|
||||||
</template>
|
</MessageBlock>
|
||||||
<span v-else><button class="sign-in-btn" @click="createChild">Create</button> a child</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="loading" class="loading">Loading...</div>
|
<div v-else-if="loading" class="loading">Loading...</div>
|
||||||
|
|
||||||
@@ -383,35 +384,6 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: white;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
font-size: 2.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading,
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 200px;
|
|
||||||
color: white;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
@@ -547,33 +519,6 @@ h1 {
|
|||||||
background: var(--child-image-bg);
|
background: var(--child-image-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* modal */
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--modal-bg);
|
|
||||||
color: var(--modal-text);
|
|
||||||
padding: 1.25rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
width: 360px;
|
|
||||||
max-width: calc(100% - 32px);
|
|
||||||
box-shadow: var(--modal-shadow);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal h3 {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.points {
|
.points {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
color: var(--points-color);
|
color: var(--points-color);
|
||||||
@@ -581,23 +526,4 @@ h1 {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sign-in-btn {
|
|
||||||
background: var(--sign-in-btn-bg);
|
|
||||||
color: var(--sign-in-btn-color);
|
|
||||||
border: 2px solid var(--sign-in-btn-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
margin-right: 0.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background 0.18s,
|
|
||||||
color 0.18s;
|
|
||||||
}
|
|
||||||
.sign-in-btn:hover {
|
|
||||||
background: var(--sign-in-btn-hover-bg);
|
|
||||||
color: var(--sign-in-btn-hover-color);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
6
web/vue-app/src/components/shared/ErrorMessage.vue
Normal file
6
web/vue-app/src/components/shared/ErrorMessage.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="message" class="error-message" aria-live="polite">{{ message }}</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ message: string }>()
|
||||||
|
</script>
|
||||||
163
web/vue-app/src/components/shared/ItemList.vue
Normal file
163
web/vue-app/src/components/shared/ItemList.vue
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { getCachedImageUrl } from '@/common/imageCache'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
fetchUrl: string
|
||||||
|
itemKey: string
|
||||||
|
itemFields: string[]
|
||||||
|
imageField?: string
|
||||||
|
selectable?: boolean
|
||||||
|
deletable?: boolean
|
||||||
|
onEdit?: (id: string) => void
|
||||||
|
onDelete?: (id: string) => void
|
||||||
|
filterFn?: (item: any) => boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits(['edit', 'delete', 'loading-complete'])
|
||||||
|
|
||||||
|
const items = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const selectedItems = ref<string[]>([])
|
||||||
|
|
||||||
|
const fetchItems = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const resp = await fetch(props.fetchUrl)
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
const data = await resp.json()
|
||||||
|
let itemList = data[props.itemKey] || []
|
||||||
|
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
||||||
|
await Promise.all(
|
||||||
|
itemList.map(async (item: any) => {
|
||||||
|
if (props.imageField && item[props.imageField]) {
|
||||||
|
try {
|
||||||
|
item.image_url = await getCachedImageUrl(item[props.imageField])
|
||||||
|
} catch {
|
||||||
|
item.image_url = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
items.value = itemList
|
||||||
|
if (props.selectable) selectedItems.value = []
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
|
||||||
|
items.value = []
|
||||||
|
if (props.selectable) selectedItems.value = []
|
||||||
|
} finally {
|
||||||
|
emit('loading-complete', items.value.length)
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchItems)
|
||||||
|
watch(() => props.fetchUrl, fetchItems)
|
||||||
|
|
||||||
|
const handleEdit = (id: string) => {
|
||||||
|
emit('edit', id)
|
||||||
|
props.onEdit?.(id)
|
||||||
|
}
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
emit('delete', id)
|
||||||
|
props.onDelete?.(id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="listbox">
|
||||||
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
<div v-else-if="error" class="error">{{ error }}</div>
|
||||||
|
<div v-else-if="items.length === 0" class="empty">No items found.</div>
|
||||||
|
<div v-else>
|
||||||
|
<div v-for="(item, idx) in items" :key="item.id">
|
||||||
|
<div class="list-item" @click="handleEdit(item.id)">
|
||||||
|
<img v-if="item.image_url" :src="item.image_url" alt="Item" class="list-image" />
|
||||||
|
<span class="list-name">{{ item.name }}</span>
|
||||||
|
<span v-if="item.points !== undefined" class="list-points">{{ item.points }} pts</span>
|
||||||
|
<span v-if="item.cost !== undefined" class="list-cost">{{ item.cost }} pts</span>
|
||||||
|
<input
|
||||||
|
v-if="props.selectable"
|
||||||
|
type="checkbox"
|
||||||
|
class="list-checkbox"
|
||||||
|
v-model="selectedItems"
|
||||||
|
:value="item.id"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="props.deletable"
|
||||||
|
class="delete-btn"
|
||||||
|
@click.stop="handleDelete(item.id)"
|
||||||
|
aria-label="Delete item"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<!-- SVG icon here -->
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
|
||||||
|
<path
|
||||||
|
d="M7 7l6 6M13 7l-6 6"
|
||||||
|
stroke="#ef4444"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="idx < items.length - 1" class="list-separator"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 2px outset var(--list-item-border-good);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.2rem 1rem;
|
||||||
|
background: var(--list-item-bg);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: border 0.18s;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.list-item.bad {
|
||||||
|
border-color: var(--list-item-border-bad);
|
||||||
|
background: var(--list-item-bg-bad);
|
||||||
|
}
|
||||||
|
.list-item.good {
|
||||||
|
border-color: var(--list-item-border-good);
|
||||||
|
background: var(--list-item-bg-good);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image styles */
|
||||||
|
.list-image {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-right: 0.7rem;
|
||||||
|
background: var(--list-image-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Name/label styles */
|
||||||
|
.list-name {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Points/cost/requested text */
|
||||||
|
.list-value {
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
190
web/vue-app/src/components/shared/LoginButton.vue
Normal file
190
web/vue-app/src/components/shared/LoginButton.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { eventBus } from '@/common/eventBus'
|
||||||
|
import { authenticateParent, isParentAuthenticated, logoutParent } from '../../stores/auth'
|
||||||
|
import '@/assets/modal.css'
|
||||||
|
import '@/assets/actions-shared.css'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const show = ref(false)
|
||||||
|
const pin = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const pinInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const dropdownOpen = ref(false)
|
||||||
|
|
||||||
|
const open = async () => {
|
||||||
|
pin.value = ''
|
||||||
|
error.value = ''
|
||||||
|
show.value = true
|
||||||
|
await nextTick()
|
||||||
|
pinInput.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
show.value = false
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const isDigits = /^\d{4,6}$/.test(pin.value)
|
||||||
|
if (!isDigits) {
|
||||||
|
error.value = 'Enter 4–6 digits'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pin.value !== '1179') {
|
||||||
|
error.value = 'Incorrect PIN'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate parent and navigate
|
||||||
|
authenticateParent()
|
||||||
|
close()
|
||||||
|
router.push('/parent')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logoutParent()
|
||||||
|
router.push('/child')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDropdown() {
|
||||||
|
dropdownOpen.value = !dropdownOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/logout', { method: 'POST' })
|
||||||
|
logoutParent()
|
||||||
|
router.push('/auth')
|
||||||
|
} catch {
|
||||||
|
// Optionally show error
|
||||||
|
}
|
||||||
|
dropdownOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToProfile() {
|
||||||
|
router.push('/parent/profile')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
eventBus.on('open-login', open)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
eventBus.off('open-login', open)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div style="position: relative">
|
||||||
|
<button v-if="!isParentAuthenticated" @click="open" aria-label="Parent login" class="login-btn">
|
||||||
|
Parent
|
||||||
|
</button>
|
||||||
|
<div v-else style="display: inline-block; position: relative">
|
||||||
|
<button
|
||||||
|
@click="toggleDropdown"
|
||||||
|
aria-label="Parent menu"
|
||||||
|
class="login-btn"
|
||||||
|
style="min-width: 80px"
|
||||||
|
>
|
||||||
|
Parent ▼
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="dropdownOpen"
|
||||||
|
class="dropdown-menu"
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e6e6e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
min-width: 120px;
|
||||||
|
z-index: 10;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<button class="menu-item" @click="goToProfile" style="width: 100%; text-align: left">
|
||||||
|
Profile
|
||||||
|
</button>
|
||||||
|
<button class="menu-item" @click="handleLogout" style="width: 100%; text-align: left">
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
<button class="menu-item danger" @click="signOut" style="width: 100%; text-align: left">
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="show" class="modal-backdrop" @click.self="close">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>Enter parent PIN</h3>
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<input
|
||||||
|
ref="pinInput"
|
||||||
|
v-model="pin"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="\d*"
|
||||||
|
maxlength="6"
|
||||||
|
placeholder="4–6 digits"
|
||||||
|
class="pin-input"
|
||||||
|
/>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">OK</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* modal */
|
||||||
|
|
||||||
|
.pin-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e6e6e6;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.menu-item {
|
||||||
|
padding: 1rem 0.9rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--menu-item-color, #333);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.menu-item + .menu-item {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.menu-item:hover {
|
||||||
|
background: var(--menu-item-hover-bg, rgba(0, 0, 0, 0.04));
|
||||||
|
}
|
||||||
|
.menu-item.danger {
|
||||||
|
color: var(--menu-item-danger, #ff4d4f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.menu-item {
|
||||||
|
padding: 0.85rem 0.7rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.menu-item + .menu-item {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
28
web/vue-app/src/components/shared/MessageBlock.vue
Normal file
28
web/vue-app/src/components/shared/MessageBlock.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<div class="message-block">
|
||||||
|
<div>{{ message }}</div>
|
||||||
|
<div class="sub-message">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import '@/assets/global.css'
|
||||||
|
defineProps<{ message: string }>()
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.message-block {
|
||||||
|
margin: 2rem 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--message-block-color);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.sub-message {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--sub-message-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
32
web/vue-app/src/components/shared/ModalDialog.vue
Normal file
32
web/vue-app/src/components/shared/ModalDialog.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<div class="modal-backdrop">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
// No script content needed unless you want to add props or logic
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal-dialog {
|
||||||
|
background: #fff;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||||
|
max-width: 340px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
6
web/vue-app/src/components/shared/SuccessMessage.vue
Normal file
6
web/vue-app/src/components/shared/SuccessMessage.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="message" class="success-message" aria-live="polite">{{ message }}</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ message: string }>()
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, defineEmits, nextTick } from 'vue'
|
import { ref, onMounted, computed, defineEmits, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import ImagePicker from '@/components/ImagePicker.vue'
|
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||||
import '@/assets/edit-forms.css'
|
import '@/assets/edit-forms.css'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -139,17 +139,36 @@ function onAddImage({ id, file }: { id: string; file: File }) {
|
|||||||
<h2>{{ isEdit ? 'Edit Task' : 'Create Task' }}</h2>
|
<h2>{{ isEdit ? 'Edit Task' : 'Create Task' }}</h2>
|
||||||
<div v-if="loading" class="loading-message">Loading task...</div>
|
<div v-if="loading" class="loading-message">Loading task...</div>
|
||||||
<form v-else @submit.prevent="submit" class="task-form">
|
<form v-else @submit.prevent="submit" class="task-form">
|
||||||
<label>
|
<div class="group">
|
||||||
|
<label for="task-name">
|
||||||
Task Name
|
Task Name
|
||||||
<input ref="nameInput" v-model="name" type="text" required maxlength="64" />
|
<input
|
||||||
|
id="task-name"
|
||||||
|
ref="nameInput"
|
||||||
|
v-model="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
maxlength="64"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<label for="task-points">
|
||||||
Task Points
|
Task Points
|
||||||
<input v-model.number="points" type="number" min="1" max="100" required />
|
<input
|
||||||
|
id="task-points"
|
||||||
|
v-model.number="points"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<label for="task-type">
|
||||||
Task Type
|
Task Type
|
||||||
<div class="good-bad-toggle">
|
<div class="good-bad-toggle" id="task-type">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:class="['toggle-btn', isGood ? 'good-active' : '']"
|
:class="['toggle-btn', isGood ? 'good-active' : '']"
|
||||||
@@ -166,7 +185,9 @@ function onAddImage({ id, file }: { id: string; file: File }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<div class="form-group image-picker-group">
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<label for="task-image">Image</label>
|
||||||
<ImagePicker
|
<ImagePicker
|
||||||
id="task-image"
|
id="task-image"
|
||||||
v-model="selectedImageId"
|
v-model="selectedImageId"
|
||||||
|
|||||||
@@ -7,12 +7,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TaskList
|
<ItemList
|
||||||
v-else
|
v-else
|
||||||
ref="taskListRef"
|
fetchUrl="/api/task/list"
|
||||||
:deletable="true"
|
itemKey="tasks"
|
||||||
@edit-task="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
|
:itemFields="['id', 'name', 'points', 'is_good', 'image_id']"
|
||||||
@delete-task="confirmDeleteTask"
|
imageField="image_id"
|
||||||
|
selectable
|
||||||
|
deletable
|
||||||
|
@edit="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
|
||||||
|
@delete="confirmDeleteTask"
|
||||||
@loading-complete="(count) => (taskCountRef = count)"
|
@loading-complete="(count) => (taskCountRef = count)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -40,6 +44,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import TaskList from './TaskList.vue'
|
import TaskList from './TaskList.vue'
|
||||||
|
import ItemList from '../shared/ItemList.vue'
|
||||||
|
|
||||||
const $router = useRouter()
|
const $router = useRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, nextTick, defineProps, defineEmits, computed } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
|
||||||
import { getCachedImageUrl } from '../common/imageCache'
|
import { getCachedImageUrl } from '@/common/imageCache'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue?: string | null // selected image id or local-upload
|
modelValue?: string | null // selected image id or local-upload
|
||||||
@@ -203,7 +203,7 @@ function updateLocalImage(url: string, file: File) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="picker">
|
||||||
<div class="image-scroll">
|
<div class="image-scroll">
|
||||||
<div v-if="loadingImages" class="loading-images">Loading images...</div>
|
<div v-if="loadingImages" class="loading-images">Loading images...</div>
|
||||||
<div v-else class="image-list">
|
<div v-else class="image-list">
|
||||||
@@ -268,6 +268,13 @@ function updateLocalImage(url: string, file: File) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.image-scroll {
|
.image-scroll {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0.7rem 0 0.2rem 0;
|
margin: 0.7rem 0 0.2rem 0;
|
||||||
50
web/vue-app/src/layout/AuthLayout.vue
Normal file
50
web/vue-app/src/layout/AuthLayout.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout-root">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="back-btn-container">
|
||||||
|
<button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0">← Back</button>
|
||||||
|
</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
</header>
|
||||||
|
<main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
// route to the auth landing page instead of using browser history
|
||||||
|
router.push({ name: 'AuthLanding' }).catch(() => {
|
||||||
|
// fallback to a safe path if named route isn't available
|
||||||
|
window.location.href = '/auth'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// hide back button specifically on the Auth landing route
|
||||||
|
const showBack = computed(
|
||||||
|
() =>
|
||||||
|
route.name !== 'AuthLanding' && route.name !== 'VerifySignup' && route.name !== 'ResetPassword',
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Only keep styles unique to ChildLayout */
|
||||||
|
|
||||||
|
.topbar > .spacer {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import LoginButton from '../components/LoginButton.vue'
|
import LoginButton from '../components/shared/LoginButton.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import LoginButton from '../components/LoginButton.vue'
|
import LoginButton from '../components/shared/LoginButton.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { watch } from 'vue'
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import ChildLayout from '../layout/ChildLayout.vue'
|
import ChildLayout from '../layout/ChildLayout.vue'
|
||||||
import ParentLayout from '../layout/ParentLayout.vue'
|
import ParentLayout from '../layout/ParentLayout.vue'
|
||||||
import ChildrenListView from '../components/ChildrenListView.vue'
|
import ChildrenListView from '../components/shared/ChildrenListView.vue'
|
||||||
import ChildView from '../components/child/ChildView.vue'
|
import ChildView from '../components/child/ChildView.vue'
|
||||||
import ParentView from '../components/child/ParentView.vue'
|
import ParentView from '../components/child/ParentView.vue'
|
||||||
import TaskView from '../components/task/TaskView.vue'
|
import TaskView from '../components/task/TaskView.vue'
|
||||||
@@ -12,8 +13,49 @@ import ChildEditView from '@/components/child/ChildEditView.vue'
|
|||||||
import TaskAssignView from '@/components/child/TaskAssignView.vue'
|
import TaskAssignView from '@/components/child/TaskAssignView.vue'
|
||||||
import RewardAssignView from '@/components/child/RewardAssignView.vue'
|
import RewardAssignView from '@/components/child/RewardAssignView.vue'
|
||||||
import NotificationView from '@/components/notification/NotificationView.vue'
|
import NotificationView from '@/components/notification/NotificationView.vue'
|
||||||
|
import AuthLayout from '@/layout/AuthLayout.vue'
|
||||||
|
import Signup from '@/components/auth/Signup.vue'
|
||||||
|
import AuthLanding from '@/components/auth/AuthLanding.vue'
|
||||||
|
import Login from '@/components/auth/Login.vue'
|
||||||
|
import { isUserLoggedIn, isParentAuthenticated, isAuthReady } from '../stores/auth'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/auth',
|
||||||
|
component: AuthLayout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'AuthLanding',
|
||||||
|
component: AuthLanding,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'signup',
|
||||||
|
name: 'Signup',
|
||||||
|
component: Signup,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
name: 'Login',
|
||||||
|
component: Login,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'verify',
|
||||||
|
name: 'VerifySignup',
|
||||||
|
component: () => import('@/components/auth/VerifySignup.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'forgot-password',
|
||||||
|
name: 'ForgotPassword',
|
||||||
|
component: () => import('@/components/auth/ForgotPassword.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'reset-password',
|
||||||
|
name: 'ResetPassword',
|
||||||
|
component: () => import('@/components/auth/ResetPassword.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/child',
|
path: '/child',
|
||||||
component: ChildLayout,
|
component: ChildLayout,
|
||||||
@@ -110,6 +152,11 @@ const routes = [
|
|||||||
component: NotificationView,
|
component: NotificationView,
|
||||||
props: false,
|
props: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'profile',
|
||||||
|
name: 'UserProfile',
|
||||||
|
component: () => import('@/components/profile/UserProfile.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -117,7 +164,6 @@ const routes = [
|
|||||||
redirect: '/child',
|
redirect: '/child',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
import { isParentAuthenticated } from '../stores/auth'
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -125,13 +171,35 @@ const router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Auth guard
|
// Auth guard
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
if (to.meta.requiresAuth && !isParentAuthenticated.value) {
|
if (!isAuthReady.value) {
|
||||||
// Redirect to /child if trying to access /parent without auth
|
await new Promise((resolve) => {
|
||||||
next('/child')
|
const stop = watch(isAuthReady, (ready) => {
|
||||||
} else {
|
if (ready) {
|
||||||
next()
|
stop()
|
||||||
|
resolve(true)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always allow access to /auth routes
|
||||||
|
if (to.path.startsWith('/auth')) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not logged in, redirect to /auth
|
||||||
|
if (!isUserLoggedIn.value) {
|
||||||
|
return next('/auth')
|
||||||
|
}
|
||||||
|
|
||||||
|
// If logged in but not parent-authenticated, redirect to /child (unless already there)
|
||||||
|
if (!isParentAuthenticated.value && !to.path.startsWith('/child')) {
|
||||||
|
return next('/child')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, allow navigation
|
||||||
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,11 +1,40 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
export const isParentAuthenticated = ref(false)
|
export const isParentAuthenticated = ref(false)
|
||||||
|
export const isUserLoggedIn = ref(false)
|
||||||
|
export const isAuthReady = ref(false)
|
||||||
|
export const currentUserId = ref('')
|
||||||
|
|
||||||
export function authenticateParent() {
|
export function authenticateParent() {
|
||||||
isParentAuthenticated.value = true
|
isParentAuthenticated.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout() {
|
export function logoutParent() {
|
||||||
isParentAuthenticated.value = false
|
isParentAuthenticated.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loginUser() {
|
||||||
|
isUserLoggedIn.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logoutUser() {
|
||||||
|
isUserLoggedIn.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/me', { method: 'GET' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
currentUserId.value = data.id
|
||||||
|
isUserLoggedIn.value = true
|
||||||
|
} else {
|
||||||
|
isUserLoggedIn.value = false
|
||||||
|
currentUserId.value = ''
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
isUserLoggedIn.value = false
|
||||||
|
currentUserId.value = ''
|
||||||
|
}
|
||||||
|
isAuthReady.value = true
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user