diff --git a/api/auth_api.py b/api/auth_api.py new file mode 100644 index 0000000..cdecf9b --- /dev/null +++ b/api/auth_api.py @@ -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 diff --git a/api/error_codes.py b/api/error_codes.py new file mode 100644 index 0000000..2ec88f0 --- /dev/null +++ b/api/error_codes.py @@ -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" \ No newline at end of file diff --git a/db/db.py b/db/db.py index 703b204..8032a0d 100644 --- a/db/db.py +++ b/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() diff --git a/main.py b/main.py index 250b0aa..96a0a4d 100644 --- a/main.py +++ b/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") diff --git a/web/vue-app/src/ErrorMessage.vue b/web/vue-app/src/ErrorMessage.vue new file mode 100644 index 0000000..592865f --- /dev/null +++ b/web/vue-app/src/ErrorMessage.vue @@ -0,0 +1,6 @@ + + diff --git a/web/vue-app/src/ModalDialog.vue b/web/vue-app/src/ModalDialog.vue new file mode 100644 index 0000000..07cc67d --- /dev/null +++ b/web/vue-app/src/ModalDialog.vue @@ -0,0 +1,32 @@ + + + diff --git a/web/vue-app/src/SuccessMessage.vue b/web/vue-app/src/SuccessMessage.vue new file mode 100644 index 0000000..0b2c618 --- /dev/null +++ b/web/vue-app/src/SuccessMessage.vue @@ -0,0 +1,6 @@ + + diff --git a/web/vue-app/src/assets/actions-shared.css b/web/vue-app/src/assets/actions-shared.css index 5fd0871..a79909f 100644 --- a/web/vue-app/src/assets/actions-shared.css +++ b/web/vue-app/src/assets/actions-shared.css @@ -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); +} diff --git a/web/vue-app/src/assets/button-shared.css b/web/vue-app/src/assets/button-shared.css index f57210c..d88ef28 100644 --- a/web/vue-app/src/assets/button-shared.css +++ b/web/vue-app/src/assets/button-shared.css @@ -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); +} diff --git a/web/vue-app/src/assets/global.css b/web/vue-app/src/assets/global.css index 0287089..6e30c93 100644 --- a/web/vue-app/src/assets/global.css +++ b/web/vue-app/src/assets/global.css @@ -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; diff --git a/web/vue-app/src/common/api.ts b/web/vue-app/src/common/api.ts new file mode 100644 index 0000000..268349f --- /dev/null +++ b/web/vue-app/src/common/api.ts @@ -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) +} diff --git a/web/vue-app/src/common/errorCodes.ts b/web/vue-app/src/common/errorCodes.ts new file mode 100644 index 0000000..bcc36d8 --- /dev/null +++ b/web/vue-app/src/common/errorCodes.ts @@ -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' diff --git a/web/vue-app/src/components/auth/AuthLanding.vue b/web/vue-app/src/components/auth/AuthLanding.vue new file mode 100644 index 0000000..7df8276 --- /dev/null +++ b/web/vue-app/src/components/auth/AuthLanding.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/web/vue-app/src/components/auth/Login.vue b/web/vue-app/src/components/auth/Login.vue new file mode 100644 index 0000000..7bbf23d --- /dev/null +++ b/web/vue-app/src/components/auth/Login.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/web/vue-app/src/components/auth/Signup.vue b/web/vue-app/src/components/auth/Signup.vue new file mode 100644 index 0000000..dac6251 --- /dev/null +++ b/web/vue-app/src/components/auth/Signup.vue @@ -0,0 +1,369 @@ + + + + + diff --git a/web/vue-app/src/components/auth/VerifySignup.vue b/web/vue-app/src/components/auth/VerifySignup.vue new file mode 100644 index 0000000..7bd84f0 --- /dev/null +++ b/web/vue-app/src/components/auth/VerifySignup.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/web/vue-app/src/layout/AuthLayout.vue b/web/vue-app/src/layout/AuthLayout.vue new file mode 100644 index 0000000..4debddf --- /dev/null +++ b/web/vue-app/src/layout/AuthLayout.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/web/vue-app/src/router/index.ts b/web/vue-app/src/router/index.ts index 065a0ec..e69ca0e 100644 --- a/web/vue-app/src/router/index.ts +++ b/web/vue-app/src/router/index.ts @@ -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,