feat: add parent PIN setup functionality and email notifications
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 23s
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 23s
- Implemented User model updates to include PIN and related fields. - Created email sender utility for sending verification and reset emails. - Developed ParentPinSetup component for setting up a parent PIN with verification code. - Enhanced UserProfile and EntityEditForm components to support new features. - Updated routing to include PIN setup and authentication checks. - Added styles for new components and improved existing styles for consistency. - Introduced loading states and error handling in various components.
This commit is contained in:
@@ -3,7 +3,7 @@ import secrets, jwt
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
from flask_mail import Mail, Message
|
from backend.utils.email_instance import email_sender
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -18,32 +18,15 @@ from db.db import users_db
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
auth_api = Blueprint('auth_api', __name__)
|
auth_api = Blueprint('auth_api', __name__)
|
||||||
UserQuery = Query()
|
UserQuery = Query()
|
||||||
mail = Mail()
|
|
||||||
TOKEN_EXPIRY_MINUTES = 60*4
|
TOKEN_EXPIRY_MINUTES = 60*4
|
||||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
|
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
|
||||||
|
|
||||||
|
|
||||||
def send_verification_email(to_email, token):
|
def send_verification_email(to_email, token):
|
||||||
verify_url = f"{current_app.config['FRONTEND_URL']}/auth/verify?token={token}"
|
email_sender.send_verification_email(to_email, 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):
|
def send_reset_password_email(to_email, token):
|
||||||
reset_url = f"{current_app.config['FRONTEND_URL']}/auth/reset-password?token={token}"
|
email_sender.send_reset_password_email(to_email, 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'])
|
@auth_api.route('/signup', methods=['POST'])
|
||||||
def signup():
|
def signup():
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ from models.user import User
|
|||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
from db.db import users_db
|
from db.db import users_db
|
||||||
import jwt
|
import jwt
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import smtplib
|
||||||
|
from backend.utils.email_instance import email_sender
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
user_api = Blueprint('user_api', __name__)
|
user_api = Blueprint('user_api', __name__)
|
||||||
UserQuery = Query()
|
UserQuery = Query()
|
||||||
@@ -31,6 +36,25 @@ def get_profile():
|
|||||||
'image_id': user.image_id
|
'image_id': user.image_id
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
@user_api.route('/user/profile', methods=['PUT'])
|
||||||
|
def update_profile():
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
|
data = request.get_json()
|
||||||
|
# Only allow first_name, last_name, image_id to be updated
|
||||||
|
first_name = data.get('first_name')
|
||||||
|
last_name = data.get('last_name')
|
||||||
|
image_id = data.get('image_id')
|
||||||
|
if first_name is not None:
|
||||||
|
user.first_name = first_name
|
||||||
|
if last_name is not None:
|
||||||
|
user.last_name = last_name
|
||||||
|
if image_id is not None:
|
||||||
|
user.image_id = image_id
|
||||||
|
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||||
|
return jsonify({'message': 'Profile updated'}), 200
|
||||||
|
|
||||||
@user_api.route('/user/image', methods=['PUT'])
|
@user_api.route('/user/image', methods=['PUT'])
|
||||||
def update_image():
|
def update_image():
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
@@ -43,3 +67,82 @@ def update_image():
|
|||||||
user.image_id = image_id
|
user.image_id = image_id
|
||||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||||
return jsonify({'message': 'Image updated', 'image_id': image_id}), 200
|
return jsonify({'message': 'Image updated', 'image_id': image_id}), 200
|
||||||
|
|
||||||
|
@user_api.route('/user/check-pin', methods=['POST'])
|
||||||
|
def check_pin():
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
|
data = request.get_json()
|
||||||
|
pin = data.get('pin')
|
||||||
|
if not pin:
|
||||||
|
return jsonify({'error': 'Missing pin'}), 400
|
||||||
|
if user.pin and pin == user.pin:
|
||||||
|
return jsonify({'valid': True}), 200
|
||||||
|
return jsonify({'valid': False}), 200
|
||||||
|
|
||||||
|
@user_api.route('/user/has-pin', methods=['GET'])
|
||||||
|
def has_pin():
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
|
return jsonify({'has_pin': bool(user.pin)}), 200
|
||||||
|
|
||||||
|
@user_api.route('/user/request-pin-setup', methods=['POST'])
|
||||||
|
def request_pin_setup():
|
||||||
|
user = get_current_user()
|
||||||
|
if not user or not user.verified:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
|
# Generate 6-digit/character code
|
||||||
|
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
||||||
|
user.pin_setup_code = code
|
||||||
|
user.pin_setup_code_created = datetime.utcnow().isoformat()
|
||||||
|
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||||
|
# Send email
|
||||||
|
send_pin_setup_email(user.email, code)
|
||||||
|
return jsonify({'message': 'Verification code sent to your email.'}), 200
|
||||||
|
|
||||||
|
def send_pin_setup_email(email, code):
|
||||||
|
# Use the reusable email sender
|
||||||
|
email_sender.send_pin_setup_email(email, code)
|
||||||
|
|
||||||
|
@user_api.route('/user/verify-pin-setup', methods=['POST'])
|
||||||
|
def verify_pin_setup():
|
||||||
|
user = get_current_user()
|
||||||
|
if not user or not user.verified:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
|
data = request.get_json()
|
||||||
|
code = data.get('code')
|
||||||
|
if not code:
|
||||||
|
return jsonify({'error': 'Missing code'}), 400
|
||||||
|
if not user.pin_setup_code or not user.pin_setup_code_created:
|
||||||
|
return jsonify({'error': 'No code requested'}), 400
|
||||||
|
# Check expiry (10 min)
|
||||||
|
created = datetime.fromisoformat(user.pin_setup_code_created)
|
||||||
|
if datetime.utcnow() > created + timedelta(minutes=10):
|
||||||
|
return jsonify({'error': 'Code expired'}), 400
|
||||||
|
if code.strip().upper() != user.pin_setup_code.upper():
|
||||||
|
return jsonify({'error': 'Invalid code'}), 400
|
||||||
|
return jsonify({'message': 'Code verified'}), 200
|
||||||
|
|
||||||
|
@user_api.route('/user/set-pin', methods=['POST'])
|
||||||
|
def set_pin():
|
||||||
|
user = get_current_user()
|
||||||
|
if not user or not user.verified:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
|
data = request.get_json()
|
||||||
|
pin = data.get('pin')
|
||||||
|
if not pin or not pin.isdigit() or not (4 <= len(pin) <= 6):
|
||||||
|
return jsonify({'error': 'PIN must be 4-6 digits'}), 400
|
||||||
|
# Only allow if code was recently verified
|
||||||
|
if not user.pin_setup_code or not user.pin_setup_code_created:
|
||||||
|
return jsonify({'error': 'No code verified'}), 400
|
||||||
|
created = datetime.fromisoformat(user.pin_setup_code_created)
|
||||||
|
if datetime.utcnow() > created + timedelta(minutes=10):
|
||||||
|
return jsonify({'error': 'Code expired'}), 400
|
||||||
|
# Set pin, clear code
|
||||||
|
user.pin = pin
|
||||||
|
user.pin_setup_code = ''
|
||||||
|
user.pin_setup_code_created = None
|
||||||
|
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||||
|
return jsonify({'message': 'Parent PIN set'}), 200
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import sys
|
|||||||
from flask import Flask, request, jsonify
|
from flask import Flask, request, jsonify
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
|
||||||
from api.auth_api import auth_api, mail
|
from api.auth_api import auth_api
|
||||||
from api.child_api import child_api
|
from api.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 api.user_api import user_api
|
||||||
from config.version import get_full_version
|
from config.version import get_full_version
|
||||||
|
|
||||||
|
from backend.utils.email_instance import email_sender
|
||||||
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
|
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
|
||||||
@@ -44,10 +46,11 @@ app.config.update(
|
|||||||
FRONTEND_URL='https://localhost:5173', # Adjust as needed
|
FRONTEND_URL='https://localhost:5173', # Adjust as needed
|
||||||
SECRET_KEY='supersecretkey' # Replace with a secure key in production
|
SECRET_KEY='supersecretkey' # Replace with a secure key in production
|
||||||
)
|
)
|
||||||
mail.init_app(app)
|
|
||||||
|
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
||||||
|
email_sender.init_app(app)
|
||||||
|
|
||||||
@app.route("/version")
|
@app.route("/version")
|
||||||
def api_version():
|
def api_version():
|
||||||
return jsonify({"version": get_full_version()})
|
return jsonify({"version": get_full_version()})
|
||||||
@@ -84,6 +87,5 @@ createDefaultTasks()
|
|||||||
createDefaultRewards()
|
createDefaultRewards()
|
||||||
start_background_threads()
|
start_background_threads()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)
|
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)
|
||||||
@@ -13,6 +13,9 @@ class User(BaseModel):
|
|||||||
reset_token: str | None = None
|
reset_token: str | None = None
|
||||||
reset_token_created: str | None = None
|
reset_token_created: str | None = None
|
||||||
image_id: str | None = None
|
image_id: str | None = None
|
||||||
|
pin: str = ''
|
||||||
|
pin_setup_code: str = ''
|
||||||
|
pin_setup_code_created: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, d: dict):
|
def from_dict(cls, d: dict):
|
||||||
@@ -27,6 +30,9 @@ class User(BaseModel):
|
|||||||
reset_token=d.get('reset_token'),
|
reset_token=d.get('reset_token'),
|
||||||
reset_token_created=d.get('reset_token_created'),
|
reset_token_created=d.get('reset_token_created'),
|
||||||
image_id=d.get('image_id'),
|
image_id=d.get('image_id'),
|
||||||
|
pin=d.get('pin', ''),
|
||||||
|
pin_setup_code=d.get('pin_setup_code', ''),
|
||||||
|
pin_setup_code_created=d.get('pin_setup_code_created'),
|
||||||
id=d.get('id'),
|
id=d.get('id'),
|
||||||
created_at=d.get('created_at'),
|
created_at=d.get('created_at'),
|
||||||
updated_at=d.get('updated_at')
|
updated_at=d.get('updated_at')
|
||||||
@@ -45,6 +51,9 @@ class User(BaseModel):
|
|||||||
'verify_token_created': self.verify_token_created,
|
'verify_token_created': self.verify_token_created,
|
||||||
'reset_token': self.reset_token,
|
'reset_token': self.reset_token,
|
||||||
'reset_token_created': self.reset_token_created,
|
'reset_token_created': self.reset_token_created,
|
||||||
'image_id': self.image_id
|
'image_id': self.image_id,
|
||||||
|
'pin': self.pin,
|
||||||
|
'pin_setup_code': self.pin_setup_code,
|
||||||
|
'pin_setup_code_created': self.pin_setup_code_created
|
||||||
})
|
})
|
||||||
return base
|
return base
|
||||||
|
|||||||
3
backend/utils/email_instance.py
Normal file
3
backend/utils/email_instance.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from backend.utils.email_sender import EmailSender
|
||||||
|
|
||||||
|
email_sender = EmailSender()
|
||||||
65
backend/utils/email_sender.py
Normal file
65
backend/utils/email_sender.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import os
|
||||||
|
from flask import current_app
|
||||||
|
from flask_mail import Mail, Message
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
class EmailSender:
|
||||||
|
def __init__(self, app=None):
|
||||||
|
self.mail = None
|
||||||
|
if app is not None:
|
||||||
|
self.init_app(app)
|
||||||
|
|
||||||
|
def init_app(self, app):
|
||||||
|
self.mail = Mail(app)
|
||||||
|
|
||||||
|
def send_verification_email(self, to_email, token):
|
||||||
|
verify_url = f"{current_app.config['FRONTEND_URL']}/auth/verify?token={token}"
|
||||||
|
html_body = f'Click <a href="{verify_url}">here</a> to verify your account.'
|
||||||
|
msg = Message(
|
||||||
|
subject="Verify your account",
|
||||||
|
recipients=[to_email],
|
||||||
|
html=html_body,
|
||||||
|
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
|
||||||
|
)
|
||||||
|
if self.mail:
|
||||||
|
self.mail.send(msg)
|
||||||
|
else:
|
||||||
|
print(f"[EMAIL to {to_email}] Verification: {verify_url}")
|
||||||
|
|
||||||
|
def send_reset_password_email(self, to_email, token):
|
||||||
|
reset_url = f"{current_app.config['FRONTEND_URL']}/auth/reset-password?token={token}"
|
||||||
|
html_body = f'Click <a href="{reset_url}">here</a> to reset your password.'
|
||||||
|
msg = Message(
|
||||||
|
subject="Reset your password",
|
||||||
|
recipients=[to_email],
|
||||||
|
html=html_body,
|
||||||
|
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
|
||||||
|
)
|
||||||
|
if self.mail:
|
||||||
|
self.mail.send(msg)
|
||||||
|
else:
|
||||||
|
print(f"[EMAIL to {to_email}] Reset password: {reset_url}")
|
||||||
|
|
||||||
|
def send_pin_setup_email(self, to_email, code):
|
||||||
|
html_body = f"""
|
||||||
|
<div style='font-family:sans-serif;'>
|
||||||
|
<h2>Set up your Parent PIN</h2>
|
||||||
|
<p>To set your Parent PIN, enter the following code in the app:</p>
|
||||||
|
<div style='font-size:2rem; font-weight:bold; letter-spacing:0.2em; margin:1.5rem 0;'>{code}</div>
|
||||||
|
<p>This code is valid for 10 minutes.</p>
|
||||||
|
<p>If you did not request this, you can ignore this email.</p>
|
||||||
|
<hr>
|
||||||
|
<div style='color:#888;font-size:0.95rem;'>Reward App</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
msg = Message(
|
||||||
|
subject="Set up your Parent PIN",
|
||||||
|
recipients=[to_email],
|
||||||
|
html=html_body,
|
||||||
|
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
|
||||||
|
)
|
||||||
|
if self.mail:
|
||||||
|
self.mail.send(msg)
|
||||||
|
else:
|
||||||
|
print(f"[EMAIL to {to_email}] Parent PIN setup code: {code}")
|
||||||
@@ -11,16 +11,6 @@
|
|||||||
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-view h2,
|
|
||||||
.edit-view h2,
|
|
||||||
.child-edit-view h2,
|
|
||||||
.reward-edit-view h2,
|
|
||||||
.task-edit-view h2 {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
color: var(--form-heading);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-form,
|
.profile-form,
|
||||||
.task-form,
|
.task-form,
|
||||||
.reward-form,
|
.reward-form,
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
/* Edit view container for forms */
|
||||||
|
.edit-view {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
background: var(--form-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 24px var(--form-shadow);
|
||||||
|
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
||||||
|
}
|
||||||
/*buttons*/
|
/*buttons*/
|
||||||
.btn {
|
.btn {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -98,3 +107,15 @@
|
|||||||
background: var(--sign-in-btn-hover-bg);
|
background: var(--sign-in-btn-hover-bg);
|
||||||
color: var(--sign-in-btn-hover-color);
|
color: var(--sign-in-btn-hover-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Errors and info*/
|
||||||
|
.info-message {
|
||||||
|
color: var(--info-points, #2563eb);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.error-message {
|
||||||
|
color: var(--error, #e53e3e);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|||||||
236
frontend/vue-app/src/components/auth/ParentPinSetup.vue
Normal file
236
frontend/vue-app/src/components/auth/ParentPinSetup.vue
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pin-setup-view">
|
||||||
|
<div v-if="step === 1">
|
||||||
|
<h2>Set up your Parent PIN</h2>
|
||||||
|
<p>
|
||||||
|
To protect your account, you need to set a Parent PIN. This PIN is required to access parent
|
||||||
|
features.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary" @click="requestCode" :disabled="loading">
|
||||||
|
{{ loading ? 'Sending...' : 'Send Verification Code' }}
|
||||||
|
</button>
|
||||||
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
|
<div v-if="info" class="info-message">{{ info }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="step === 2">
|
||||||
|
<h2>Enter Verification Code</h2>
|
||||||
|
<p>
|
||||||
|
We've sent a 6-digit code to your email. Enter it below to continue. The code is valid for
|
||||||
|
10 minutes.
|
||||||
|
</p>
|
||||||
|
<input v-model="code" maxlength="6" class="code-input" placeholder="6-digit code" />
|
||||||
|
<div class="button-group">
|
||||||
|
<button v-if="!loading" class="btn btn-primary" @click="verifyCode">Verify Code</button>
|
||||||
|
<button class="btn btn-link" @click="resendCode" v-if="showResend" :disabled="loading">
|
||||||
|
Resend Code
|
||||||
|
</button>
|
||||||
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="step === 3">
|
||||||
|
<h2>Create Parent PIN</h2>
|
||||||
|
<p>Enter a new 4–6 digit Parent PIN. This will be required for parent access.</p>
|
||||||
|
<input
|
||||||
|
v-model="pin"
|
||||||
|
maxlength="6"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="\d*"
|
||||||
|
class="pin-input"
|
||||||
|
placeholder="New PIN"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="pin2"
|
||||||
|
maxlength="6"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="\d*"
|
||||||
|
class="pin-input"
|
||||||
|
placeholder="Confirm PIN"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-primary" @click="setPin" :disabled="loading">
|
||||||
|
{{ loading ? 'Saving...' : 'Set PIN' }}
|
||||||
|
</button>
|
||||||
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="step === 4">
|
||||||
|
<h2>Parent PIN Set!</h2>
|
||||||
|
<p>Your Parent PIN has been set. You can now use it to access parent features.</p>
|
||||||
|
<button class="btn btn-primary" @click="goBack">Back</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { logoutParent } from '@/stores/auth'
|
||||||
|
import '@/assets/styles.css'
|
||||||
|
import '@/assets/colors.css'
|
||||||
|
|
||||||
|
const step = ref(1)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const info = ref('')
|
||||||
|
const code = ref('')
|
||||||
|
const pin = ref('')
|
||||||
|
const pin2 = ref('')
|
||||||
|
|
||||||
|
const showResend = ref(false)
|
||||||
|
let resendTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
async function requestCode() {
|
||||||
|
error.value = ''
|
||||||
|
info.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/request-pin-setup', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Failed to send code')
|
||||||
|
info.value = 'A verification code has been sent to your email.'
|
||||||
|
step.value = 2
|
||||||
|
code.value = ''
|
||||||
|
showResend.value = false
|
||||||
|
if (resendTimeout) clearTimeout(resendTimeout)
|
||||||
|
resendTimeout = setTimeout(() => {
|
||||||
|
showResend.value = true
|
||||||
|
}, 10000)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'Failed to send code.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resendCode() {
|
||||||
|
error.value = ''
|
||||||
|
info.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/request-pin-setup', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Failed to resend code')
|
||||||
|
info.value = 'A new verification code has been sent to your email.'
|
||||||
|
// Stay on code input step
|
||||||
|
step.value = 2
|
||||||
|
code.value = ''
|
||||||
|
showResend.value = false
|
||||||
|
if (resendTimeout) clearTimeout(resendTimeout)
|
||||||
|
resendTimeout = setTimeout(() => {
|
||||||
|
showResend.value = true
|
||||||
|
}, 10000)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'Failed to resend code.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// When entering step 2, start the resend timer
|
||||||
|
watch(step, (newStep) => {
|
||||||
|
if (newStep === 2) {
|
||||||
|
showResend.value = false
|
||||||
|
if (resendTimeout) clearTimeout(resendTimeout)
|
||||||
|
resendTimeout = setTimeout(() => {
|
||||||
|
showResend.value = true
|
||||||
|
}, 10000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (step.value === 2) {
|
||||||
|
showResend.value = false
|
||||||
|
if (resendTimeout) clearTimeout(resendTimeout)
|
||||||
|
resendTimeout = setTimeout(() => {
|
||||||
|
showResend.value = true
|
||||||
|
}, 10000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function verifyCode() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/verify-pin-setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code: code.value }),
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Invalid code')
|
||||||
|
step.value = 3
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'Invalid code.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPin() {
|
||||||
|
error.value = ''
|
||||||
|
if (!/^\d{4,6}$/.test(pin.value)) {
|
||||||
|
error.value = 'PIN must be 4–6 digits.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pin.value !== pin2.value) {
|
||||||
|
error.value = 'PINs do not match.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/set-pin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pin: pin.value }),
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Failed to set PIN')
|
||||||
|
step.value = 4
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'Failed to set PIN.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
logoutParent()
|
||||||
|
router.push('/child')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pin-setup-view {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 2.5rem auto;
|
||||||
|
background: var(--form-bg, #fff);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 24px var(--form-shadow, #e6e6e6);
|
||||||
|
padding: 2.2rem 2.2rem 1.5rem 2.2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pin-input,
|
||||||
|
.code-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,21 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<EntityEditForm
|
<div class="edit-view">
|
||||||
entityLabel="Child"
|
<EntityEditForm
|
||||||
:fields="fields"
|
entityLabel="Child"
|
||||||
:initialData="initialData"
|
:fields="fields"
|
||||||
:isEdit="isEdit"
|
:initialData="initialData"
|
||||||
:loading="loading"
|
:isEdit="isEdit"
|
||||||
:error="error"
|
:loading="loading"
|
||||||
@submit="handleSubmit"
|
:error="error"
|
||||||
@cancel="handleCancel"
|
@submit="handleSubmit"
|
||||||
@add-image="handleAddImage"
|
@cancel="handleCancel"
|
||||||
/>
|
@add-image="handleAddImage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<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 EntityEditForm from '../shared/EntityEditForm.vue'
|
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||||
|
import '@/assets/styles.css'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -130,3 +133,4 @@ function handleCancel() {
|
|||||||
router.back()
|
router.back()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<style scoped></style>
|
||||||
|
|||||||
@@ -1,86 +1,101 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="profile-view">
|
<div class="edit-view">
|
||||||
<h2>User Profile</h2>
|
<EntityEditForm
|
||||||
<form class="profile-form" @submit.prevent>
|
entityLabel="User Profile"
|
||||||
<div class="group">
|
:fields="fields"
|
||||||
<label for="child-image">Image</label>
|
:initialData="initialData"
|
||||||
<ImagePicker
|
:isEdit="true"
|
||||||
id="child-image"
|
:loading="loading"
|
||||||
v-model="selectedImageId"
|
:error="errorMsg"
|
||||||
:image-type="1"
|
:title="'User Profile'"
|
||||||
@add-image="onAddImage"
|
@submit="handleSubmit"
|
||||||
/>
|
@add-image="onAddImage"
|
||||||
|
>
|
||||||
|
<template #custom-field-email="{ modelValue }">
|
||||||
|
<div class="email-actions">
|
||||||
|
<input id="email" type="email" :value="modelValue" disabled class="readonly-input" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link align-start btn-link-space"
|
||||||
|
@click="goToChangeParentPin"
|
||||||
|
>
|
||||||
|
Change Parent Pin
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link align-start btn-link-space"
|
||||||
|
@click="resetPassword"
|
||||||
|
:disabled="resetting"
|
||||||
|
>
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</EntityEditForm>
|
||||||
|
<div v-if="errorMsg" class="error-message" aria-live="polite">{{ errorMsg }}</div>
|
||||||
|
<ModalDialog
|
||||||
|
v-if="showModal"
|
||||||
|
:title="modalTitle"
|
||||||
|
:subtitle="modalSubtitle"
|
||||||
|
@close="handleModalClose"
|
||||||
|
>
|
||||||
|
<div class="modal-message">{{ modalMessage }}</div>
|
||||||
|
<div class="modal-actions" v-if="!resetting">
|
||||||
|
<button class="btn btn-primary" @click="handleModalClose">OK</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="group">
|
</ModalDialog>
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
import { useRouter } from 'vue-router'
|
||||||
import { getCachedImageUrl } from '@/common/imageCache'
|
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||||
import '@/assets/edit-forms.css'
|
import ModalDialog from '../shared/ModalDialog.vue'
|
||||||
import '@/assets/actions-shared.css'
|
import '@/assets/styles.css'
|
||||||
import '@/assets/button-shared.css'
|
import '@/assets/colors.css'
|
||||||
|
|
||||||
const firstName = ref('')
|
const router = useRouter()
|
||||||
const lastName = ref('')
|
const loading = ref(false)
|
||||||
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 errorMsg = ref('')
|
||||||
const successMsg = ref('')
|
const successMsg = ref('')
|
||||||
const resetting = ref(false)
|
const resetting = ref(false)
|
||||||
|
const localImageFile = ref<File | null>(null)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const modalTitle = ref('')
|
||||||
|
const modalSubtitle = ref('')
|
||||||
|
const modalMessage = ref('')
|
||||||
|
|
||||||
|
const initialData = ref({
|
||||||
|
image_id: null,
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
email: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
{ name: 'image_id', label: 'Image', type: 'image', imageType: 1 },
|
||||||
|
{ name: 'first_name', label: 'First Name', type: 'text', required: true, maxlength: 64 },
|
||||||
|
{ name: 'last_name', label: 'Last Name', type: 'text', required: true, maxlength: 64 },
|
||||||
|
{ name: 'email', label: 'Email Address', type: 'custom' },
|
||||||
|
]
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/user/profile')
|
const res = await fetch('/api/user/profile')
|
||||||
if (!res.ok) throw new Error('Failed to load profile')
|
if (!res.ok) throw new Error('Failed to load profile')
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
firstName.value = data.first_name || ''
|
initialData.value = {
|
||||||
lastName.value = data.last_name || ''
|
image_id: data.image_id || null,
|
||||||
email.value = data.email || ''
|
first_name: data.first_name || '',
|
||||||
avatarId.value = data.image_id || null
|
last_name: data.last_name || '',
|
||||||
selectedImageId.value = data.image_id || null
|
email: data.email || '',
|
||||||
|
|
||||||
// Use imageCache to get avatar URL
|
|
||||||
if (avatarId.value) {
|
|
||||||
avatarUrl.value = await getCachedImageUrl(avatarId.value)
|
|
||||||
} else {
|
|
||||||
avatarUrl.value = '/static/avatar-default.png'
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
errorMsg.value = 'Could not load user profile.'
|
errorMsg.value = 'Could not load user profile.'
|
||||||
}
|
} finally {
|
||||||
})
|
loading.value = false
|
||||||
|
|
||||||
// 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'
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -89,7 +104,7 @@ function onAddImage({ id, file }: { id: string; file: File }) {
|
|||||||
localImageFile.value = file
|
localImageFile.value = file
|
||||||
} else {
|
} else {
|
||||||
localImageFile.value = null
|
localImageFile.value = null
|
||||||
selectedImageId.value = id
|
initialData.value.image_id = id
|
||||||
updateAvatar(id)
|
updateAvatar(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,15 +119,13 @@ async function updateAvatar(imageId: string) {
|
|||||||
body: JSON.stringify({ image_id: imageId }),
|
body: JSON.stringify({ image_id: imageId }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to update avatar')
|
if (!res.ok) throw new Error('Failed to update avatar')
|
||||||
// Update avatarId, which will trigger the watcher to update avatarUrl
|
initialData.value.image_id = imageId
|
||||||
avatarId.value = imageId
|
|
||||||
successMsg.value = 'Avatar updated!'
|
successMsg.value = 'Avatar updated!'
|
||||||
} catch {
|
} catch {
|
||||||
errorMsg.value = 'Failed to update avatar.'
|
errorMsg.value = 'Failed to update avatar.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If uploading a new image file
|
|
||||||
watch(localImageFile, async (file) => {
|
watch(localImageFile, async (file) => {
|
||||||
if (!file) return
|
if (!file) return
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
@@ -128,35 +141,102 @@ watch(localImageFile, async (file) => {
|
|||||||
})
|
})
|
||||||
if (!resp.ok) throw new Error('Image upload failed')
|
if (!resp.ok) throw new Error('Image upload failed')
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
selectedImageId.value = data.id
|
initialData.value.image_id = data.id
|
||||||
await updateAvatar(data.id)
|
await updateAvatar(data.id)
|
||||||
} catch {
|
} catch {
|
||||||
errorMsg.value = 'Failed to upload avatar image.'
|
errorMsg.value = 'Failed to upload avatar image.'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function handleSubmit(form: {
|
||||||
|
image_id: string | null
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
email: string
|
||||||
|
}) {
|
||||||
|
errorMsg.value = ''
|
||||||
|
loading.value = true
|
||||||
|
fetch('/api/user/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
first_name: form.first_name,
|
||||||
|
last_name: form.last_name,
|
||||||
|
image_id: form.image_id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error('Failed to update profile')
|
||||||
|
modalTitle.value = 'Profile Updated'
|
||||||
|
modalSubtitle.value = ''
|
||||||
|
modalMessage.value = 'Your profile was updated successfully.'
|
||||||
|
showModal.value = true
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
errorMsg.value = 'Failed to update profile.'
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleModalClose() {
|
||||||
|
showModal.value = false
|
||||||
|
// Log out user and route to auth landing page
|
||||||
|
try {
|
||||||
|
await fetch('/api/logout', { method: 'POST' })
|
||||||
|
} catch {}
|
||||||
|
// Optionally clear any local auth state here if needed
|
||||||
|
router.push({ name: 'AuthLanding' })
|
||||||
|
}
|
||||||
|
|
||||||
async function resetPassword() {
|
async function resetPassword() {
|
||||||
|
// Show modal immediately with loading message
|
||||||
|
modalTitle.value = 'Change Password'
|
||||||
|
modalMessage.value = 'Sending password change email...'
|
||||||
|
modalSubtitle.value = ''
|
||||||
|
showModal.value = true
|
||||||
resetting.value = true
|
resetting.value = true
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
successMsg.value = ''
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/request-password-reset', {
|
const res = await fetch('/api/request-password-reset', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email: email.value }),
|
body: JSON.stringify({ email: initialData.value.email }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to send reset email')
|
if (!res.ok) throw new Error('Failed to send reset email')
|
||||||
successMsg.value =
|
modalTitle.value = 'Password Change Email Sent'
|
||||||
'If this email is registered, you will receive a password reset link shortly.'
|
modalMessage.value =
|
||||||
|
'If this email is registered, you will receive a password change link shortly.'
|
||||||
} catch {
|
} catch {
|
||||||
errorMsg.value = 'Failed to send password reset email.'
|
modalTitle.value = 'Password Change Failed'
|
||||||
|
modalMessage.value = 'Failed to send password change email.'
|
||||||
} finally {
|
} finally {
|
||||||
resetting.value = false
|
resetting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goToChangeParentPin() {
|
||||||
|
router.push({ name: 'ParentPinSetup' })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* ...existing styles... */
|
||||||
|
.email-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.align-start {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link-space {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
.success-message {
|
.success-message {
|
||||||
color: var(--success, #16a34a);
|
color: var(--success, #16a34a);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -166,4 +246,14 @@ async function resetPassword() {
|
|||||||
font-size: 0.98rem;
|
font-size: 0.98rem;
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
}
|
}
|
||||||
|
.readonly-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--form-input-bg, #f5f5f5);
|
||||||
|
color: var(--form-label, #888);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<EntityEditForm
|
<div class="edit-view">
|
||||||
entityLabel="Reward"
|
<EntityEditForm
|
||||||
:fields="fields"
|
entityLabel="Reward"
|
||||||
:initialData="initialData"
|
:fields="fields"
|
||||||
:isEdit="isEdit"
|
:initialData="initialData"
|
||||||
:loading="loading"
|
:isEdit="isEdit"
|
||||||
:error="error"
|
:loading="loading"
|
||||||
@submit="handleSubmit"
|
:error="error"
|
||||||
@cancel="handleCancel"
|
@submit="handleSubmit"
|
||||||
@add-image="handleAddImage"
|
@cancel="handleCancel"
|
||||||
/>
|
@add-image="handleAddImage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||||
|
import '@/assets/styles.css'
|
||||||
|
|
||||||
const props = defineProps<{ id?: string }>()
|
const props = defineProps<{ id?: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const deletingChildId = ref<string | number | null>(null)
|
|||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
|
|
||||||
const openChildEditor = (child: Child, evt?: Event) => {
|
const openChildEditor = (child: Child, evt?: Event) => {
|
||||||
|
console.log(' opening child editor for child id ', child.id)
|
||||||
evt?.stopPropagation()
|
evt?.stopPropagation()
|
||||||
router.push({ name: 'ChildEditView', params: { id: child.id } })
|
router.push({ name: 'ChildEditView', params: { id: child.id } })
|
||||||
}
|
}
|
||||||
@@ -136,6 +137,7 @@ const fetchChildren = async (): Promise<Child[]> => {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
console.log(' fetched children list: ', childList)
|
||||||
return childList
|
return childList
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
|
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
|
||||||
@@ -172,6 +174,7 @@ onUnmounted(() => {
|
|||||||
const shouldIgnoreNextCardClick = ref(false)
|
const shouldIgnoreNextCardClick = ref(false)
|
||||||
|
|
||||||
const onDocClick = (e: MouseEvent) => {
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
console.log(' document click detected ')
|
||||||
if (activeMenuFor.value !== null) {
|
if (activeMenuFor.value !== null) {
|
||||||
const path = (e.composedPath && e.composedPath()) || (e as any).path || []
|
const path = (e.composedPath && e.composedPath()) || (e as any).path || []
|
||||||
const clickedInsideKebab = path.some((node: unknown) => {
|
const clickedInsideKebab = path.some((node: unknown) => {
|
||||||
|
|||||||
@@ -1,51 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="entity-edit-view">
|
<h2>{{ title ?? (isEdit ? `Edit ${entityLabel}` : `Create ${entityLabel}`) }}</h2>
|
||||||
<h2>{{ isEdit ? `Edit ${entityLabel}` : `Create ${entityLabel}` }}</h2>
|
<div v-if="loading" class="loading-message">Loading {{ entityLabel.toLowerCase() }}...</div>
|
||||||
<div v-if="loading" class="loading-message">Loading {{ entityLabel.toLowerCase() }}...</div>
|
<form v-else @submit.prevent="submit" class="entity-form">
|
||||||
<form v-else @submit.prevent="submit" class="entity-form">
|
<template v-for="field in fields" :key="field.name">
|
||||||
<template v-for="field in fields" :key="field.name">
|
<div class="group">
|
||||||
<div class="group">
|
<label :for="field.name">
|
||||||
<label :for="field.name">
|
{{ field.label }}
|
||||||
{{ field.label }}
|
<!-- Custom field slot -->
|
||||||
<!-- Custom field slot -->
|
<slot
|
||||||
<slot
|
:name="`custom-field-${field.name}`"
|
||||||
:name="`custom-field-${field.name}`"
|
:modelValue="formData[field.name]"
|
||||||
:modelValue="formData[field.name]"
|
:update="(val) => (formData[field.name] = val)"
|
||||||
:update="(val) => (formData[field.name] = val)"
|
>
|
||||||
>
|
<!-- Default rendering if no slot provided -->
|
||||||
<!-- Default rendering if no slot provided -->
|
<input
|
||||||
<input
|
v-if="field.type === 'text' || field.type === 'number'"
|
||||||
v-if="field.type === 'text' || field.type === 'number'"
|
:id="field.name"
|
||||||
:id="field.name"
|
v-model="formData[field.name]"
|
||||||
v-model="formData[field.name]"
|
:type="field.type"
|
||||||
:type="field.type"
|
:required="field.required"
|
||||||
:required="field.required"
|
:maxlength="field.maxlength"
|
||||||
:maxlength="field.maxlength"
|
:min="field.min"
|
||||||
:min="field.min"
|
:max="field.max"
|
||||||
:max="field.max"
|
/>
|
||||||
/>
|
<ImagePicker
|
||||||
<ImagePicker
|
v-else-if="field.type === 'image'"
|
||||||
v-else-if="field.type === 'image'"
|
:id="field.name"
|
||||||
:id="field.name"
|
v-model="formData[field.name]"
|
||||||
v-model="formData[field.name]"
|
:image-type="field.imageType || 1"
|
||||||
:image-type="field.imageType || 1"
|
@add-image="onAddImage"
|
||||||
@add-image="onAddImage"
|
/>
|
||||||
/>
|
</slot>
|
||||||
</slot>
|
</label>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
|
||||||
{{ isEdit ? 'Save' : 'Create' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</template>
|
||||||
</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="loading || !isDirty">
|
||||||
|
{{ isEdit ? 'Save' : 'Create' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -73,6 +71,7 @@ const props = defineProps<{
|
|||||||
isEdit?: boolean
|
isEdit?: boolean
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
error?: string | null
|
error?: string | null
|
||||||
|
title?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits(['submit', 'cancel', 'add-image'])
|
const emit = defineEmits(['submit', 'cancel', 'add-image'])
|
||||||
@@ -107,9 +106,46 @@ function onCancel() {
|
|||||||
function submit() {
|
function submit() {
|
||||||
emit('submit', { ...formData.value })
|
emit('submit', { ...formData.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Editable field names (exclude custom fields that are not editable)
|
||||||
|
const editableFieldNames = props.fields
|
||||||
|
.filter((f) => f.type !== 'custom' || f.name === 'is_good' || f.type === 'image')
|
||||||
|
.map((f) => f.name)
|
||||||
|
|
||||||
|
const isDirty = ref(false)
|
||||||
|
|
||||||
|
function checkDirty() {
|
||||||
|
isDirty.value = editableFieldNames.some((key) => {
|
||||||
|
return JSON.stringify(formData.value[key]) !== JSON.stringify(props.initialData?.[key])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => ({ ...formData.value }),
|
||||||
|
() => {
|
||||||
|
checkDirty()
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initialData,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
formData.value = { ...newVal }
|
||||||
|
checkDirty()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
h2 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--form-heading);
|
||||||
|
}
|
||||||
.entity-edit-view {
|
.entity-edit-view {
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { eventBus } from '@/common/eventBus'
|
import { eventBus } from '@/common/eventBus'
|
||||||
import { authenticateParent, isParentAuthenticated, logoutParent } from '../../stores/auth'
|
import { authenticateParent, isParentAuthenticated, logoutParent } from '../../stores/auth'
|
||||||
import '@/assets/modal.css'
|
import '@/assets/styles.css'
|
||||||
import '@/assets/actions-shared.css'
|
import '@/assets/colors.css'
|
||||||
|
import ModalDialog from './ModalDialog.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const show = ref(false)
|
const show = ref(false)
|
||||||
@@ -14,6 +15,21 @@ const pinInput = ref<HTMLInputElement | null>(null)
|
|||||||
const dropdownOpen = ref(false)
|
const dropdownOpen = ref(false)
|
||||||
|
|
||||||
const open = async () => {
|
const open = async () => {
|
||||||
|
// Check if user has a pin
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/has-pin', { credentials: 'include' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Error checking PIN')
|
||||||
|
if (!data.has_pin) {
|
||||||
|
console.log('No PIN set, redirecting to setup')
|
||||||
|
// Route to PIN setup view
|
||||||
|
router.push('/parent/pin-setup')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Network error'
|
||||||
|
return
|
||||||
|
}
|
||||||
pin.value = ''
|
pin.value = ''
|
||||||
error.value = ''
|
error.value = ''
|
||||||
show.value = true
|
show.value = true
|
||||||
@@ -26,22 +42,36 @@ const close = () => {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = () => {
|
const submit = async () => {
|
||||||
const isDigits = /^\d{4,6}$/.test(pin.value)
|
const isDigits = /^\d{4,6}$/.test(pin.value)
|
||||||
if (!isDigits) {
|
if (!isDigits) {
|
||||||
error.value = 'Enter 4–6 digits'
|
error.value = 'Enter 4–6 digits'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pin.value !== '1179') {
|
try {
|
||||||
error.value = 'Incorrect PIN'
|
const res = await fetch('/api/user/check-pin', {
|
||||||
return
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pin: pin.value }),
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
error.value = data.error || 'Error validating PIN'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!data.valid) {
|
||||||
|
error.value = 'Incorrect PIN'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Authenticate parent and navigate
|
||||||
|
authenticateParent()
|
||||||
|
close()
|
||||||
|
router.push('/parent')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Network error'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate parent and navigate
|
|
||||||
authenticateParent()
|
|
||||||
close()
|
|
||||||
router.push('/parent')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@@ -117,27 +147,24 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="show" class="modal-backdrop" @click.self="close">
|
<ModalDialog v-if="show" title="Enter parent PIN" @click.self="close" @close="close">
|
||||||
<div class="modal">
|
<form @submit.prevent="submit">
|
||||||
<h3>Enter parent PIN</h3>
|
<input
|
||||||
<form @submit.prevent="submit">
|
ref="pinInput"
|
||||||
<input
|
v-model="pin"
|
||||||
ref="pinInput"
|
inputmode="numeric"
|
||||||
v-model="pin"
|
pattern="\d*"
|
||||||
inputmode="numeric"
|
maxlength="6"
|
||||||
pattern="\d*"
|
placeholder="4–6 digits"
|
||||||
maxlength="6"
|
class="pin-input"
|
||||||
placeholder="4–6 digits"
|
/>
|
||||||
class="pin-input"
|
<div class="actions modal-actions">
|
||||||
/>
|
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
|
||||||
<div class="actions">
|
<button type="submit" class="btn btn-primary">OK</button>
|
||||||
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">OK</button>
|
</form>
|
||||||
</div>
|
<div v-if="error" class="error modal-message">{{ error }}</div>
|
||||||
</form>
|
</ModalDialog>
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ button.toggle-btn.bad-active {
|
|||||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||||
|
import '@/assets/styles.css'
|
||||||
|
|
||||||
const props = defineProps<{ id?: string }>()
|
const props = defineProps<{ id?: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -183,34 +184,36 @@ function handleCancel() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<EntityEditForm
|
<div class="edit-view">
|
||||||
entityLabel="Task"
|
<EntityEditForm
|
||||||
:fields="fields"
|
entityLabel="Task"
|
||||||
:initialData="initialData"
|
:fields="fields"
|
||||||
:isEdit="isEdit"
|
:initialData="initialData"
|
||||||
:loading="loading"
|
:isEdit="isEdit"
|
||||||
:error="error"
|
:loading="loading"
|
||||||
@submit="handleSubmit"
|
:error="error"
|
||||||
@cancel="handleCancel"
|
@submit="handleSubmit"
|
||||||
@add-image="handleAddImage"
|
@cancel="handleCancel"
|
||||||
>
|
@add-image="handleAddImage"
|
||||||
<template #custom-field-is_good="{ modelValue, update }">
|
>
|
||||||
<div class="good-bad-toggle">
|
<template #custom-field-is_good="{ modelValue, update }">
|
||||||
<button
|
<div class="good-bad-toggle">
|
||||||
type="button"
|
<button
|
||||||
:class="['toggle-btn', modelValue ? 'good-active' : '']"
|
type="button"
|
||||||
@click="update(true)"
|
:class="['toggle-btn', modelValue ? 'good-active' : '']"
|
||||||
>
|
@click="update(true)"
|
||||||
Good
|
>
|
||||||
</button>
|
Good
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
:class="['toggle-btn', !modelValue ? 'bad-active' : '']"
|
type="button"
|
||||||
@click="update(false)"
|
:class="['toggle-btn', !modelValue ? 'bad-active' : '']"
|
||||||
>
|
@click="update(false)"
|
||||||
Bad
|
>
|
||||||
</button>
|
Bad
|
||||||
</div>
|
</button>
|
||||||
</template>
|
</div>
|
||||||
</EntityEditForm>
|
</template>
|
||||||
|
</EntityEditForm>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const handleBack = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide view-selector if on ParentPinSetup (PIN setup flow)
|
||||||
|
const hideViewSelector = computed(() => route.name === 'ParentPinSetup')
|
||||||
|
|
||||||
const showBack = computed(
|
const showBack = computed(
|
||||||
() =>
|
() =>
|
||||||
!(
|
!(
|
||||||
@@ -45,7 +48,7 @@ onMounted(async () => {
|
|||||||
<div class="back-btn-container">
|
<div class="back-btn-container">
|
||||||
<button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0">← Back</button>
|
<button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0">← Back</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="view-selector">
|
<nav v-if="!hideViewSelector" class="view-selector">
|
||||||
<button
|
<button
|
||||||
:class="{
|
:class="{
|
||||||
active: [
|
active: [
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import Signup from '@/components/auth/Signup.vue'
|
|||||||
import AuthLanding from '@/components/auth/AuthLanding.vue'
|
import AuthLanding from '@/components/auth/AuthLanding.vue'
|
||||||
import Login from '@/components/auth/Login.vue'
|
import Login from '@/components/auth/Login.vue'
|
||||||
import { isUserLoggedIn, isParentAuthenticated, isAuthReady } from '../stores/auth'
|
import { isUserLoggedIn, isParentAuthenticated, isAuthReady } from '../stores/auth'
|
||||||
|
import ParentPinSetup from '@/components/auth/ParentPinSetup.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -157,6 +158,12 @@ const routes = [
|
|||||||
name: 'UserProfile',
|
name: 'UserProfile',
|
||||||
component: () => import('@/components/profile/UserProfile.vue'),
|
component: () => import('@/components/profile/UserProfile.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'pin-setup',
|
||||||
|
name: 'ParentPinSetup',
|
||||||
|
component: ParentPinSetup,
|
||||||
|
meta: { requiresAuth: true, allowNoParent: true },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -172,19 +179,12 @@ const router = createRouter({
|
|||||||
|
|
||||||
// Auth guard
|
// Auth guard
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
if (!isAuthReady.value) {
|
console.log('navigating to', to.fullPath, 'from', from.fullPath)
|
||||||
await new Promise((resolve) => {
|
console.log('isParentAuthenticated:', isParentAuthenticated.value)
|
||||||
const stop = watch(isAuthReady, (ready) => {
|
console.trace()
|
||||||
if (ready) {
|
|
||||||
stop()
|
|
||||||
resolve(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always allow access to /auth routes
|
// Always allow /auth and /parent/pin-setup
|
||||||
if (to.path.startsWith('/auth')) {
|
if (to.path.startsWith('/auth') || to.name === 'ParentPinSetup') {
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,13 +193,22 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
return next('/auth')
|
return next('/auth')
|
||||||
}
|
}
|
||||||
|
|
||||||
// If logged in but not parent-authenticated, redirect to /child (unless already there)
|
// If parent-authenticated, allow all /parent routes
|
||||||
if (!isParentAuthenticated.value && !to.path.startsWith('/child')) {
|
if (isParentAuthenticated.value && to.path.startsWith('/parent')) {
|
||||||
return next('/child')
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, allow navigation
|
// If not parent-authenticated, allow all /child routes
|
||||||
next()
|
if (!isParentAuthenticated.value && to.path.startsWith('/child')) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, redirect based on parent authentication
|
||||||
|
if (isParentAuthenticated.value) {
|
||||||
|
return next('/parent')
|
||||||
|
} else {
|
||||||
|
return next('/child')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
export const isParentAuthenticated = ref(false)
|
export const isParentAuthenticated = ref(localStorage.getItem('isParentAuthenticated') === 'true')
|
||||||
export const isUserLoggedIn = ref(false)
|
export const isUserLoggedIn = ref(false)
|
||||||
export const isAuthReady = ref(false)
|
export const isAuthReady = ref(false)
|
||||||
export const currentUserId = ref('')
|
export const currentUserId = ref('')
|
||||||
|
|
||||||
|
watch(isParentAuthenticated, (val) => {
|
||||||
|
localStorage.setItem('isParentAuthenticated', val ? 'true' : 'false')
|
||||||
|
})
|
||||||
|
|
||||||
export function authenticateParent() {
|
export function authenticateParent() {
|
||||||
isParentAuthenticated.value = true
|
isParentAuthenticated.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logoutParent() {
|
export function logoutParent() {
|
||||||
isParentAuthenticated.value = false
|
isParentAuthenticated.value = false
|
||||||
|
localStorage.removeItem('isParentAuthenticated')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loginUser() {
|
export function loginUser() {
|
||||||
|
|||||||
Reference in New Issue
Block a user