Added beginning of login functionality

This commit is contained in:
2026-01-05 15:08:29 -05:00
parent 3b7798369f
commit 46af0fb959
18 changed files with 1402 additions and 1 deletions

127
api/auth_api.py Normal file
View 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
View 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"

View File

@@ -72,6 +72,7 @@ task_path = os.path.join(base_dir, 'tasks.json')
reward_path = os.path.join(base_dir, 'rewards.json')
image_path = os.path.join(base_dir, 'images.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
_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)
_image_db = TinyDB(image_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
child_db = LockedTable(_child_db)
@@ -86,6 +88,7 @@ task_db = LockedTable(_task_db)
reward_db = LockedTable(_reward_db)
image_db = LockedTable(_image_db)
pending_reward_db = LockedTable(_pending_rewards_db)
users_db = LockedTable(_users_db)
if os.environ.get('DB_ENV', 'prod') == 'test':
child_db.truncate()
@@ -93,4 +96,5 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
reward_db.truncate()
image_db.truncate()
pending_reward_db.truncate()
users_db.truncate()

14
main.py
View File

@@ -7,6 +7,7 @@ from api.child_api import child_api
from api.image_api import image_api
from api.reward_api import reward_api
from api.task_api import task_api
from api.auth_api import auth_api, mail
from config.version import get_full_version
from events.broadcaster import Broadcaster
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(task_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)
@app.route("/version")

View 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>

View 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>

View 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>

View File

@@ -35,3 +35,22 @@
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);
}

View File

@@ -22,6 +22,13 @@
.btn-primary:focus {
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) */
.btn-secondary {
@@ -52,3 +59,44 @@
.btn-green:focus {
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);
}

View File

@@ -72,7 +72,7 @@
--fab-hover-bg: #5a67d8;
--fab-active-bg: #4c51bf;
--no-children-color: #fdfdfd;
--sub-message-color: #b5ccff;
--sub-message-color: #5d719d;
--sign-in-btn-bg: #fff;
--sign-in-btn-color: #2563eb;
--sign-in-btn-border: #2563eb;

View 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)
}

View 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'

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -12,8 +12,38 @@ import ChildEditView from '@/components/child/ChildEditView.vue'
import TaskAssignView from '@/components/child/TaskAssignView.vue'
import RewardAssignView from '@/components/child/RewardAssignView.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 = [
{
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',
component: ChildLayout,