diff --git a/app/routes/auth.py b/app/routes/auth.py index c7efe3c..8ae6d2a 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,8 +1,9 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request, abort +from urllib.parse import urlparse from flask_login import login_user, logout_user, login_required, current_user from app import db, limiter from app.models.user import User -from app.utils.forms import LoginForm, UserForm, ProfileForm +from app.utils.forms import LoginForm, UserForm, ProfileForm, ForgotPasswordForm, ResetPasswordForm from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url import logging from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT @@ -392,4 +393,127 @@ def notification_matrix(): matrix_roles = MATRIX_ROLES, defaults = MATRIX_DEFAULTS, state = state, - ) \ No newline at end of file + ) + + +# ── Forgot / Reset Password (public) ───────────────────────────────────────── + +def _send_password_reset_email(user, token, base_url=None): + """Send a password-reset link email. Mirrors _send_invite_email in customers.py.""" + from flask import current_app, render_template_string, url_for as _url_for + from flask_mail import Message + from app import mail + from urllib.parse import urlparse + import threading + + if not current_app.config.get('MAIL_SERVER'): + logger.warning('RESET EMAIL SKIPPED | no MAIL_SERVER | user=%s', user.username) + return + + effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/') + reset_link = f'{effective_base}{_url_for("auth.reset_password", token=token)}' + host = urlparse(effective_base).netloc or 'janitorialqc.local' + sender = f'noreply@{host}' + + html_body = render_template_string(""" + +
+Hi {{ name }},
+We received a request to reset your password for the Janitorial QC portal. + Click the button below to choose a new password. This link expires in + 1 hour.
+ ++ If you did not request a password reset, you can safely ignore this email. + Your password will not change. +
+
+ Or copy this URL:
+ {{ link }}
+
Janitorial QC System — do not reply.
+ +""", name=user.display_name, link=reset_link) + + text_body = ( + f'Hi {user.display_name},\n\n' + f'We received a request to reset your JQC password.\n' + f'Click the link below to reset it (expires in 1 hour):\n\n{reset_link}\n\n' + f'If you did not request this, ignore this email.\n\nJanitorial QC System' + ) + + msg = Message( + subject = '[JQC] Password reset request', + sender = sender, + recipients = [user.email], + body = text_body, + html = html_body, + ) + + app = current_app._get_current_object() + + def _send(): + with app.app_context(): + try: + mail.send(msg) + logger.info('RESET EMAIL SENT | to=%s | user=%s', user.email, user.username) + except Exception as exc: + logger.error('RESET EMAIL FAILED | to=%s | error=%s', user.email, exc) + + threading.Thread(target=_send, daemon=True).start() + + +@bp.route('/forgot-password', methods=['GET', 'POST']) +@limiter.limit('10 per hour') +def forgot_password(): + if current_user.is_authenticated: + return redirect(url_for('dashboard.index')) + + form = ForgotPasswordForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data.strip().lower()).first() + if user and user.active: + token = user.generate_set_password_token(expires_hours=1) + db.session.commit() + _send_password_reset_email(user, token, base_url=request.host_url) + logger.info('AUTH | forgot_password | user=%s | email=%s', user.username, user.email) + # Always show the same message — never reveal whether the email exists + flash( + 'If an account with that email address exists, a password reset link ' + 'has been sent. Please check your inbox (and spam folder).', + 'info' + ) + return redirect(url_for('auth.login')) + + return render_template('auth/forgot_password.html', form=form) + + +@bp.route('/reset-password/Enter the email address on your account and we'll send you a reset link.
+Hi {{ user.display_name }}. Choose a new secure password for your account.
+