Added beginning of login functionality
This commit is contained in:
127
api/auth_api.py
Normal file
127
api/auth_api.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
from flask_mail import Mail, Message
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
auth_api = Blueprint('auth_api', __name__)
|
||||||
|
UserQuery = Query()
|
||||||
|
mail = Mail()
|
||||||
|
TOKEN_EXPIRY_MINUTES = 60*4
|
||||||
|
|
||||||
|
def send_verification_email(to_email, token):
|
||||||
|
verify_url = f"{current_app.config['FRONTEND_URL']}/auth/verify?token={token}"
|
||||||
|
msg = Message(
|
||||||
|
subject="Verify your account",
|
||||||
|
recipients=[to_email],
|
||||||
|
body=f"Click to verify your account: {verify_url}",
|
||||||
|
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()
|
||||||
|
users_db.insert({
|
||||||
|
'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
|
||||||
|
})
|
||||||
|
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 = ''
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
status = 'error'
|
||||||
|
reason = 'Missing token'
|
||||||
|
code = MISSING_TOKEN
|
||||||
|
else:
|
||||||
|
user = users_db.get(Query().verify_token == token)
|
||||||
|
if not user:
|
||||||
|
status = 'error'
|
||||||
|
reason = 'Invalid token'
|
||||||
|
code = INVALID_TOKEN
|
||||||
|
else:
|
||||||
|
created_str = user.get('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:
|
||||||
|
users_db.update({'verified': True, 'verify_token': None, 'verify_token_created': None}, Query().verify_token == token)
|
||||||
|
|
||||||
|
http_status = 200 if status == 'success' else 400
|
||||||
|
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 = users_db.get(UserQuery.email == email)
|
||||||
|
if not user:
|
||||||
|
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
if user.get('verified'):
|
||||||
|
return jsonify({'error': 'Account already verified', 'code': ALREADY_VERIFIED}), 400
|
||||||
|
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
now_iso = datetime.utcnow().isoformat()
|
||||||
|
users_db.update({
|
||||||
|
'verify_token': token,
|
||||||
|
'verify_token_created': now_iso
|
||||||
|
}, 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 = users_db.get(UserQuery.email == email)
|
||||||
|
if not user or user.get('password') != password:
|
||||||
|
return jsonify({'error': 'Invalid credentials', 'code': INVALID_CREDENTIALS}), 401
|
||||||
|
|
||||||
|
if not user.get('verified'):
|
||||||
|
return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403
|
||||||
|
|
||||||
|
# In production, generate and return a session token or JWT here
|
||||||
|
return jsonify({'message': 'Login successful'}), 200
|
||||||
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"
|
||||||
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()
|
||||||
|
|
||||||
|
|||||||
14
main.py
14
main.py
@@ -7,6 +7,7 @@ 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.auth_api import auth_api, mail
|
||||||
from config.version import get_full_version
|
from config.version import get_full_version
|
||||||
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
|
||||||
@@ -28,6 +29,19 @@ 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.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
|
||||||
|
)
|
||||||
|
mail.init_app(app)
|
||||||
|
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
||||||
@app.route("/version")
|
@app.route("/version")
|
||||||
|
|||||||
6
web/vue-app/src/ErrorMessage.vue
Normal file
6
web/vue-app/src/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>
|
||||||
32
web/vue-app/src/ModalDialog.vue
Normal file
32
web/vue-app/src/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/SuccessMessage.vue
Normal file
6
web/vue-app/src/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>
|
||||||
@@ -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,44 @@
|
|||||||
.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.btn-disabled {
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0.75;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--btn-primary);
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
--fab-hover-bg: #5a67d8;
|
--fab-hover-bg: #5a67d8;
|
||||||
--fab-active-bg: #4c51bf;
|
--fab-active-bg: #4c51bf;
|
||||||
--no-children-color: #fdfdfd;
|
--no-children-color: #fdfdfd;
|
||||||
--sub-message-color: #b5ccff;
|
--sub-message-color: #5d719d;
|
||||||
--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;
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
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'
|
||||||
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>
|
||||||
291
web/vue-app/src/components/auth/Login.vue
Normal file
291
web/vue-app/src/components/auth/Login.vue
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
<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>
|
||||||
|
</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'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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'))
|
||||||
|
}
|
||||||
|
</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>
|
||||||
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 '@/ErrorMessage.vue'
|
||||||
|
import ModalDialog from '@/ModalDialog.vue'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { parseErrorResponse, 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>
|
||||||
47
web/vue-app/src/layout/AuthLayout.vue
Normal file
47
web/vue-app/src/layout/AuthLayout.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<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')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Only keep styles unique to ChildLayout */
|
||||||
|
|
||||||
|
.topbar > .spacer {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,8 +12,38 @@ 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'
|
||||||
|
|
||||||
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: '/child',
|
path: '/child',
|
||||||
component: ChildLayout,
|
component: ChildLayout,
|
||||||
|
|||||||
Reference in New Issue
Block a user