06/08 Add Forgot password function
This commit is contained in:
+126
-2
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ── 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("""<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
||||
<h2 style="color:#0d6efd;">Password Reset Request</h2>
|
||||
<p>Hi {{ name }},</p>
|
||||
<p>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
|
||||
<strong>1 hour</strong>.</p>
|
||||
<p>
|
||||
<a href="{{ link }}"
|
||||
style="background:#0d6efd;color:#fff;padding:12px 24px;
|
||||
text-decoration:none;border-radius:4px;display:inline-block;font-weight:bold;">
|
||||
Reset My Password
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#666;">
|
||||
If you did not request a password reset, you can safely ignore this email.
|
||||
Your password will not change.
|
||||
</p>
|
||||
<p style="font-size:13px;color:#888;">
|
||||
Or copy this URL:<br>
|
||||
<a href="{{ link }}" style="color:#0d6efd;">{{ link }}</a>
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
|
||||
<p style="font-size:12px;color:#888;">Janitorial QC System — do not reply.</p>
|
||||
</body>
|
||||
</html>""", 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/<token>', 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)
|
||||
Reference in New Issue
Block a user