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 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 flask_login import login_user, logout_user, login_required, current_user
|
||||||
from app import db, limiter
|
from app import db, limiter
|
||||||
from app.models.user import User
|
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
|
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
||||||
import logging
|
import logging
|
||||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
|
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,
|
matrix_roles = MATRIX_ROLES,
|
||||||
defaults = MATRIX_DEFAULTS,
|
defaults = MATRIX_DEFAULTS,
|
||||||
state = state,
|
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)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Forgot Password — Janitorial QC</title>
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
<style>
|
||||||
|
body { background: #eef0f4; font-family: 'Segoe UI', Arial, sans-serif; }
|
||||||
|
.setup-card {
|
||||||
|
max-width: 460px; margin: 80px auto;
|
||||||
|
border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.1);
|
||||||
|
}
|
||||||
|
.setup-header {
|
||||||
|
background: #1a1d23; color: #fff;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
padding: 1.5rem 1.75rem 1.25rem;
|
||||||
|
}
|
||||||
|
.setup-header h4 { margin: 0; font-weight: 600; }
|
||||||
|
.setup-header p { color: #94a3b8; font-size: .85rem; margin: .35rem 0 0; }
|
||||||
|
.setup-body { background: #fff; border-radius: 0 0 12px 12px; padding: 1.75rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="setup-card">
|
||||||
|
<div class="setup-header">
|
||||||
|
<h4><i class="bi bi-envelope-open me-2"></i>Forgot Your Password?</h4>
|
||||||
|
<p>Enter the email address on your account and we'll send you a reset link.</p>
|
||||||
|
</div>
|
||||||
|
<div class="setup-body">
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<div class="alert alert-{{ 'danger' if cat == 'danger' else 'warning' if cat == 'warning' else 'info' if cat == 'info' else 'success' }}
|
||||||
|
alert-dismissible fade show py-2 mb-3" role="alert">
|
||||||
|
{{ msg }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="email" class="form-label fw-semibold">Email Address</label>
|
||||||
|
<input type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
class="form-control {{ 'is-invalid' if form.email.errors else '' }}"
|
||||||
|
autocomplete="email"
|
||||||
|
autofocus
|
||||||
|
placeholder="you@example.com">
|
||||||
|
{% for error in form.email.errors %}
|
||||||
|
<div class="invalid-feedback">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-send me-1"></i>Send Reset Link
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<a href="{{ url_for('auth.login') }}" class="text-muted small">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Back to Login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -246,8 +246,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
{{ form.password.label(class="form-label fw-semibold") }}
|
<div class="d-flex justify-content-between align-items-baseline">
|
||||||
{{ form.password(class="form-control form-control-lg", placeholder="Enter password") }}
|
{{ form.password.label(class="form-label fw-semibold mb-0") }}
|
||||||
|
<a href="{{ url_for('auth.forgot_password') }}"
|
||||||
|
class="small text-muted text-decoration-none">Forgot password?</a>
|
||||||
|
</div>
|
||||||
|
{{ form.password(class="form-control form-control-lg mt-1", placeholder="Enter password") }}
|
||||||
{% if form.password.errors %}
|
{% if form.password.errors %}
|
||||||
<div class="text-danger small mt-1">
|
<div class="text-danger small mt-1">
|
||||||
{% for error in form.password.errors %}{{ error }}{% endfor %}
|
{% for error in form.password.errors %}{{ error }}{% endfor %}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Reset Password — Janitorial QC</title>
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
<style>
|
||||||
|
body { background: #eef0f4; font-family: 'Segoe UI', Arial, sans-serif; }
|
||||||
|
.setup-card {
|
||||||
|
max-width: 460px; margin: 80px auto;
|
||||||
|
border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.1);
|
||||||
|
}
|
||||||
|
.setup-header {
|
||||||
|
background: #1a1d23; color: #fff;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
padding: 1.5rem 1.75rem 1.25rem;
|
||||||
|
}
|
||||||
|
.setup-header h4 { margin: 0; font-weight: 600; }
|
||||||
|
.setup-header p { color: #94a3b8; font-size: .85rem; margin: .35rem 0 0; }
|
||||||
|
.setup-body { background: #fff; border-radius: 0 0 12px 12px; padding: 1.75rem; }
|
||||||
|
.req-item { font-size: .8rem; color: #64748b; }
|
||||||
|
.req-item.met { color: #16a34a; }
|
||||||
|
.strength-bar { height: 4px; border-radius: 2px; transition: all .3s; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="setup-card">
|
||||||
|
<div class="setup-header">
|
||||||
|
<h4><i class="bi bi-shield-lock me-2"></i>Set a New Password</h4>
|
||||||
|
<p>Hi {{ user.display_name }}. Choose a new secure password for your account.</p>
|
||||||
|
</div>
|
||||||
|
<div class="setup-body">
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<div class="alert alert-{{ 'danger' if cat == 'danger' else 'warning' if cat == 'warning' else 'success' }}
|
||||||
|
alert-dismissible fade show py-2 mb-3" role="alert">
|
||||||
|
{{ msg }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label fw-semibold">New Password</label>
|
||||||
|
<input type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
class="form-control {{ 'is-invalid' if form.password.errors else '' }}"
|
||||||
|
autocomplete="new-password"
|
||||||
|
autofocus>
|
||||||
|
{% for error in form.password.errors %}
|
||||||
|
<div class="invalid-feedback">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="mt-2 mb-1">
|
||||||
|
<div class="bg-light rounded" style="height:4px;">
|
||||||
|
<div id="strengthBar" class="strength-bar bg-secondary" style="width:0%;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-3 mt-2">
|
||||||
|
<span class="req-item" id="req-len">
|
||||||
|
<i class="bi bi-circle me-1"></i>8+ characters
|
||||||
|
</span>
|
||||||
|
<span class="req-item" id="req-upper">
|
||||||
|
<i class="bi bi-circle me-1"></i>Uppercase letter
|
||||||
|
</span>
|
||||||
|
<span class="req-item" id="req-num">
|
||||||
|
<i class="bi bi-circle me-1"></i>Number
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="confirm_password" class="form-label fw-semibold">Confirm Password</label>
|
||||||
|
<input type="password"
|
||||||
|
id="confirm_password"
|
||||||
|
name="confirm_password"
|
||||||
|
class="form-control {{ 'is-invalid' if form.confirm_password.errors else '' }}"
|
||||||
|
autocomplete="new-password">
|
||||||
|
{% for error in form.confirm_password.errors %}
|
||||||
|
<div class="invalid-feedback">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div id="matchFeedback" class="form-text" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-100" id="submitBtn">
|
||||||
|
<i class="bi bi-check2-circle me-1"></i>Reset Password & Log In
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var pwEl = document.getElementById('password');
|
||||||
|
var cfEl = document.getElementById('confirm_password');
|
||||||
|
var bar = document.getElementById('strengthBar');
|
||||||
|
var reqLen = document.getElementById('req-len');
|
||||||
|
var reqUpper = document.getElementById('req-upper');
|
||||||
|
var reqNum = document.getElementById('req-num');
|
||||||
|
var matchFb = document.getElementById('matchFeedback');
|
||||||
|
|
||||||
|
function markReq(el, met) {
|
||||||
|
el.className = 'req-item' + (met ? ' met' : '');
|
||||||
|
el.querySelector('i').className = (met ? 'bi bi-check-circle-fill' : 'bi bi-circle') + ' me-1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStrength(pw) {
|
||||||
|
var hasLen = pw.length >= 8;
|
||||||
|
var hasUpper = /[A-Z]/.test(pw);
|
||||||
|
var hasNum = /[0-9]/.test(pw);
|
||||||
|
var score = [hasLen, hasUpper, hasNum, pw.length >= 12].filter(Boolean).length;
|
||||||
|
markReq(reqLen, hasLen);
|
||||||
|
markReq(reqUpper, hasUpper);
|
||||||
|
markReq(reqNum, hasNum);
|
||||||
|
var color = score <= 1 ? 'danger' : score === 2 ? 'warning' : score === 3 ? 'info' : 'success';
|
||||||
|
bar.style.width = (score * 25) + '%';
|
||||||
|
bar.className = 'strength-bar bg-' + color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkMatch() {
|
||||||
|
if (!cfEl.value) { matchFb.style.display = 'none'; return; }
|
||||||
|
matchFb.style.display = '';
|
||||||
|
if (pwEl.value === cfEl.value) {
|
||||||
|
matchFb.textContent = '✓ Passwords match';
|
||||||
|
matchFb.style.color = '#16a34a';
|
||||||
|
} else {
|
||||||
|
matchFb.textContent = '✗ Passwords do not match';
|
||||||
|
matchFb.style.color = '#dc2626';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pwEl.addEventListener('input', function () { updateStrength(this.value); checkMatch(); });
|
||||||
|
cfEl.addEventListener('input', checkMatch);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -248,6 +248,16 @@ class CustomerInviteForm(FlaskForm):
|
|||||||
raise ValidationError('An account with this email address already exists.')
|
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):
|
class SetPasswordForm(FlaskForm):
|
||||||
"""Public form for customer to choose their username and password via emailed link."""
|
"""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)])
|
username = StringField('Choose a Username', validators=[DataRequired(), Length(min=3, max=100)])
|
||||||
|
|||||||
Reference in New Issue
Block a user