From 51c91e55c8e3123c52d8f32ab640c3041c912896 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 8 Jun 2026 11:01:01 -0400 Subject: [PATCH] 06/08 Add Forgot password function --- app/routes/auth.py | 128 +++++++++++++++++++- app/templates/auth/forgot_password.html | 79 +++++++++++++ app/templates/auth/login.html | 8 +- app/templates/auth/reset_password.html | 151 ++++++++++++++++++++++++ app/utils/forms.py | 10 ++ 5 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 app/templates/auth/forgot_password.html create mode 100644 app/templates/auth/reset_password.html 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(""" + + +

Password Reset Request

+

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.

+

+ + Reset My Password + +

+

+ 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/', methods=['GET', 'POST']) +def reset_password(token): + if current_user.is_authenticated: + return redirect(url_for('dashboard.index')) + + user = User.verify_set_password_token(token) + if user is None: + flash('This password reset link is invalid or has expired.', 'danger') + return redirect(url_for('auth.forgot_password')) + + form = ResetPasswordForm() + if form.validate_on_submit(): + user.set_password(form.password.data) + user.clear_set_password_token() + db.session.commit() + log_action(ACTION_UPDATE, 'User', user.id, user.username, + 'password reset via forgot-password link') + flash('Your password has been reset successfully. Please log in.', 'success') + return redirect(url_for('auth.login')) + + return render_template('auth/reset_password.html', form=form, user=user) \ No newline at end of file diff --git a/app/templates/auth/forgot_password.html b/app/templates/auth/forgot_password.html new file mode 100644 index 0000000..84d5f04 --- /dev/null +++ b/app/templates/auth/forgot_password.html @@ -0,0 +1,79 @@ + + + + + + Forgot Password — Janitorial QC + + + + + + +
+
+

Forgot Your Password?

+

Enter the email address on your account and we'll send you a reset link.

+
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} + + {% endfor %} + {% endwith %} + +
+ + +
+ + + {% for error in form.email.errors %} +
{{ error }}
+ {% endfor %} +
+ + +
+ + + +
+
+ + + + diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 3156735..89f41cd 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -246,8 +246,12 @@
- {{ form.password.label(class="form-label fw-semibold") }} - {{ form.password(class="form-control form-control-lg", placeholder="Enter password") }} +
+ {{ form.password.label(class="form-label fw-semibold mb-0") }} + Forgot password? +
+ {{ form.password(class="form-control form-control-lg mt-1", placeholder="Enter password") }} {% if form.password.errors %}
{% for error in form.password.errors %}{{ error }}{% endfor %} diff --git a/app/templates/auth/reset_password.html b/app/templates/auth/reset_password.html new file mode 100644 index 0000000..f3b5a19 --- /dev/null +++ b/app/templates/auth/reset_password.html @@ -0,0 +1,151 @@ + + + + + + Reset Password — Janitorial QC + + + + + + +
+
+

Set a New Password

+

Hi {{ user.display_name }}. Choose a new secure password for your account.

+
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} + + {% endfor %} + {% endwith %} + +
+ + +
+ + + {% for error in form.password.errors %} +
{{ error }}
+ {% endfor %} + +
+
+
+
+
+ +
+ + 8+ characters + + + Uppercase letter + + + Number + +
+
+ +
+ + + {% for error in form.confirm_password.errors %} +
{{ error }}
+ {% endfor %} + +
+ + +
+ +
+
+ + + + + diff --git a/app/utils/forms.py b/app/utils/forms.py index 8ff27a6..50b8afe 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -248,6 +248,16 @@ class CustomerInviteForm(FlaskForm): raise ValidationError('An account with this email address already exists.') +class ForgotPasswordForm(FlaskForm): + email = StringField('Email Address', validators=[DataRequired(), Email(), Length(max=255)]) + + +class ResetPasswordForm(FlaskForm): + password = PasswordField('New Password', validators=[DataRequired(), Length(min=8, max=100)]) + confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), + EqualTo('password', message='Passwords must match.')]) + + class SetPasswordForm(FlaskForm): """Public form for customer to choose their username and password via emailed link.""" username = StringField('Choose a Username', validators=[DataRequired(), Length(min=3, max=100)])