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')
|
||||
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
14
main.py
@@ -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")
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
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 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,
|
||||
|
||||
Reference in New Issue
Block a user