Apr 2 2026: separate management of internal user and customer
This commit is contained in:
@@ -22,6 +22,14 @@ class User(UserMixin, db.Model):
|
|||||||
created_at = db.Column(db.DateTime, default=now_eastern)
|
created_at = db.Column(db.DateTime, default=now_eastern)
|
||||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
# ── Customer password-setup workflow ──────────────────────────────────
|
||||||
|
# password_set: False for newly created customer accounts until they
|
||||||
|
# complete the set-password flow via emailed link.
|
||||||
|
# Always True for internal users created via UserForm.
|
||||||
|
password_set = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
set_password_token = db.Column(db.String(64), nullable=True, index=True)
|
||||||
|
set_password_token_expires = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
inspections = db.relationship('Inspection', backref='inspector', lazy='dynamic')
|
inspections = db.relationship('Inspection', backref='inspector', lazy='dynamic')
|
||||||
|
|
||||||
@@ -43,5 +51,32 @@ class User(UserMixin, db.Model):
|
|||||||
"""Return full name if set, otherwise fall back to username."""
|
"""Return full name if set, otherwise fall back to username."""
|
||||||
return self.full_name.strip() if self.full_name and self.full_name.strip() else self.username
|
return self.full_name.strip() if self.full_name and self.full_name.strip() else self.username
|
||||||
|
|
||||||
|
def generate_set_password_token(self, expires_hours=72):
|
||||||
|
"""Create a one-time set-password token valid for `expires_hours` hours."""
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
self.set_password_token = secrets.token_hex(32) # 64 hex chars
|
||||||
|
self.set_password_token_expires = now_eastern() + timedelta(hours=expires_hours)
|
||||||
|
return self.set_password_token
|
||||||
|
|
||||||
|
def clear_set_password_token(self):
|
||||||
|
"""Invalidate the token after use."""
|
||||||
|
self.set_password_token = None
|
||||||
|
self.set_password_token_expires = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def verify_set_password_token(token):
|
||||||
|
"""Return the User whose token matches, or None if invalid/expired."""
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
user = User.query.filter_by(set_password_token=token).first()
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
if user.set_password_token_expires is None:
|
||||||
|
return None
|
||||||
|
if now_eastern() > user.set_password_token_expires:
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<User {self.username}>'
|
return f'<User {self.username}>'
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ def login():
|
|||||||
if not user.active:
|
if not user.active:
|
||||||
flash('Your account has been disabled. Please contact an administrator.', 'danger')
|
flash('Your account has been disabled. Please contact an administrator.', 'danger')
|
||||||
return render_template('auth/login.html', form=form)
|
return render_template('auth/login.html', form=form)
|
||||||
|
if not user.password_set:
|
||||||
|
flash(
|
||||||
|
'Your account password has not been set yet. '
|
||||||
|
'Please check your email for the account setup link.',
|
||||||
|
'warning'
|
||||||
|
)
|
||||||
|
return render_template('auth/login.html', form=form)
|
||||||
login_user(user, remember=form.remember_me.data)
|
login_user(user, remember=form.remember_me.data)
|
||||||
# Use validated next URL — never redirect blindly to request.args['next']
|
# Use validated next URL — never redirect blindly to request.args['next']
|
||||||
next_page = _safe_next(request.args.get('next'))
|
next_page = _safe_next(request.args.get('next'))
|
||||||
|
|||||||
+178
-13
@@ -19,7 +19,7 @@ from app import db
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.project import Project, CustomerAssignment
|
from app.models.project import Project, CustomerAssignment
|
||||||
from app.models.facility import Facility
|
from app.models.facility import Facility
|
||||||
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm
|
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm, CustomerInviteForm, SetPasswordForm
|
||||||
from app.utils.decorators import admin_required
|
from app.utils.decorators import admin_required
|
||||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||||
from app.utils.scope import get_customer_scope
|
from app.utils.scope import get_customer_scope
|
||||||
@@ -97,35 +97,200 @@ def index():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Create customer ───────────────────────────────────────────────────────────
|
# ── Create customer (invitation flow) ────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/new', methods=['GET', 'POST'])
|
@bp.route('/new', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def create():
|
def create():
|
||||||
form = CustomerUserForm()
|
"""Create a customer account via email invitation.
|
||||||
|
|
||||||
|
Only Full Name and Email are required. A username is auto-generated
|
||||||
|
from the email address. A one-time set-password link is emailed to
|
||||||
|
the customer; they cannot log in until that link is used.
|
||||||
|
"""
|
||||||
|
form = CustomerInviteForm()
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
|
import re, secrets
|
||||||
|
|
||||||
|
full_name = form.full_name.data.strip()
|
||||||
|
email = form.email.data.strip().lower()
|
||||||
|
|
||||||
|
# Auto-generate username from email local part, made unique if needed
|
||||||
|
base_uname = re.sub(r'[^a-z0-9._-]', '', email.split('@')[0])[:40] or 'customer'
|
||||||
|
username = base_uname
|
||||||
|
suffix = 1
|
||||||
|
while User.query.filter_by(username=username).first():
|
||||||
|
username = f'{base_uname}{suffix}'
|
||||||
|
suffix += 1
|
||||||
|
|
||||||
|
# Create user with random placeholder password (password_set=False blocks login)
|
||||||
user = User(
|
user = User(
|
||||||
username = form.username.data,
|
username = username,
|
||||||
full_name = form.full_name.data.strip() or None,
|
full_name = full_name,
|
||||||
email = form.email.data,
|
email = email,
|
||||||
role = 'customer',
|
role = 'customer',
|
||||||
active = True,
|
active = True,
|
||||||
|
password_set = False,
|
||||||
)
|
)
|
||||||
user.set_password(form.password.data)
|
user.set_password(secrets.token_hex(32))
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
token = user.generate_set_password_token(expires_hours=72)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
logger.info('CUSTOMERS | create | admin=%s new_customer=%s email=%s',
|
|
||||||
|
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s email=%s',
|
||||||
current_user.username, user.username, user.email)
|
current_user.username, user.username, user.email)
|
||||||
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
||||||
f'role=customer; email={user.email}; created_via=customer_mgmt')
|
f'role=customer; email={user.email}; invite_sent=True')
|
||||||
flash(f'Customer account "{user.username}" created successfully.', 'success')
|
|
||||||
|
_send_invite_email(user, token)
|
||||||
|
|
||||||
|
flash(
|
||||||
|
f'Customer account created for {full_name}. '
|
||||||
|
f'An invitation email has been sent to {email} with a link to set their password.',
|
||||||
|
'success'
|
||||||
|
)
|
||||||
return redirect(url_for('customers.manage', customer_id=user.id))
|
return redirect(url_for('customers.manage', customer_id=user.id))
|
||||||
|
|
||||||
return render_template('customers/form.html', form=form, title='Create Customer Account')
|
return render_template('customers/invite.html', form=form)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_invite_email(user, token):
|
||||||
|
"""Send the account setup email to a newly created customer."""
|
||||||
|
from flask import current_app, render_template_string
|
||||||
|
from flask_mail import Message
|
||||||
|
from app import mail
|
||||||
|
import threading
|
||||||
|
|
||||||
|
if not current_app.config.get('MAIL_SERVER'):
|
||||||
|
logger.warning('INVITE EMAIL SKIPPED | no MAIL_SERVER | user=%s', user.username)
|
||||||
|
return
|
||||||
|
|
||||||
|
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
|
||||||
|
setup_link = f'{base_url}{url_for("customers.set_password", token=token)}'
|
||||||
|
sender = current_app.config.get(
|
||||||
|
'MAIL_DEFAULT_SENDER',
|
||||||
|
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'),
|
||||||
|
)
|
||||||
|
|
||||||
|
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;">Welcome to the Janitorial QC System</h2>
|
||||||
|
<p>Hi {{ name }},</p>
|
||||||
|
<p>An account has been created for you on the Janitorial QC (JQC) portal.
|
||||||
|
To get started, please set your password using the button below.</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;">
|
||||||
|
Set My Password
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size:13px;color:#666;">
|
||||||
|
This link expires in <strong>72 hours</strong>. If you did not expect this email,
|
||||||
|
you can safely ignore it.
|
||||||
|
</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=setup_link)
|
||||||
|
|
||||||
|
text_body = (
|
||||||
|
f'Hi {user.display_name},\n\n'
|
||||||
|
f'An account has been created for you on the Janitorial QC portal.\n'
|
||||||
|
f'Set your password here:\n\n{setup_link}\n\n'
|
||||||
|
f'This link expires in 72 hours.\n\nJanitorial QC System'
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = Message(
|
||||||
|
subject = '[JQC] Your account is ready — please set your password',
|
||||||
|
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('INVITE EMAIL SENT | to=%s | user=%s', user.email, user.username)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('INVITE EMAIL FAILED | to=%s | error=%s', user.email, exc)
|
||||||
|
|
||||||
|
threading.Thread(target=_send, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resend invitation email ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/<int:customer_id>/resend-invite', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def resend_invite(customer_id):
|
||||||
|
"""Generate a fresh token and resend the set-password invitation email."""
|
||||||
|
customer = User.query.get_or_404(customer_id)
|
||||||
|
if customer.role != 'customer':
|
||||||
|
flash('This action is only for customer accounts.', 'warning')
|
||||||
|
return redirect(url_for('customers.index'))
|
||||||
|
|
||||||
|
token = customer.generate_set_password_token(expires_hours=72)
|
||||||
|
customer.password_set = False
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info('CUSTOMERS | resend_invite | admin=%s customer=%s',
|
||||||
|
current_user.username, customer.username)
|
||||||
|
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||||
|
f'invite resent by {current_user.username}')
|
||||||
|
|
||||||
|
_send_invite_email(customer, token)
|
||||||
|
flash(f'Invitation email resent to {customer.email}.', 'success')
|
||||||
|
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public: set password via token ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/set-password/<token>', methods=['GET', 'POST'])
|
||||||
|
def set_password(token):
|
||||||
|
"""Public page — customer sets their password via the emailed link."""
|
||||||
|
from app.utils.forms import SetPasswordForm
|
||||||
|
user = User.verify_set_password_token(token)
|
||||||
|
if user is None:
|
||||||
|
flash(
|
||||||
|
'This password setup link is invalid or has expired. '
|
||||||
|
'Please contact your administrator to resend the invitation.',
|
||||||
|
'danger'
|
||||||
|
)
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
form = SetPasswordForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
user.set_password(form.password.data)
|
||||||
|
user.password_set = True
|
||||||
|
user.clear_set_password_token()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info('CUSTOMERS | password_set | user=%s', user.username)
|
||||||
|
log_action(ACTION_UPDATE, 'User', user.id, user.username,
|
||||||
|
'customer completed password setup via invite link')
|
||||||
|
|
||||||
|
flash('Your password has been set successfully. You can now log in.', 'success')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
return render_template('customers/set_password.html', form=form, user=user)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Edit customer ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# ── Edit customer ─────────────────────────────────────────────────────────────
|
# ── Edit customer ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/<int:customer_id>/edit', methods=['GET', 'POST'])
|
@bp.route('/<int:customer_id>/edit', methods=['GET', 'POST'])
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Create Customer Account{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 offset-md-3">
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="bi bi-person-plus-fill me-2"></i>Create Customer Account
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<p class="text-muted mb-4" style="font-size:.9rem;">
|
||||||
|
Enter the customer's name and email address. An invitation email will be
|
||||||
|
sent automatically with a secure link to set their password.
|
||||||
|
The account will be activated once they complete that step.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.full_name.label(class="form-label fw-semibold") }}
|
||||||
|
{{ form.full_name(class="form-control" + (" is-invalid" if form.full_name.errors else ""),
|
||||||
|
placeholder="e.g. Jane Smith", autofocus=true) }}
|
||||||
|
{% for error in form.full_name.errors %}
|
||||||
|
<div class="invalid-feedback">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
{{ form.email.label(class="form-label fw-semibold") }}
|
||||||
|
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
|
||||||
|
placeholder="jane@example.com") }}
|
||||||
|
{% for error in form.email.errors %}
|
||||||
|
<div class="invalid-feedback">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="form-text">
|
||||||
|
<i class="bi bi-envelope me-1"></i>
|
||||||
|
An invitation email with a password setup link will be sent to this address.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-send me-1"></i>Create & Send Invitation
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-circle me-1"></i>Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3 border-0 bg-light">
|
||||||
|
<div class="card-body py-2 px-3">
|
||||||
|
<p class="mb-1" style="font-size:.8rem;">
|
||||||
|
<i class="bi bi-info-circle me-1 text-primary"></i>
|
||||||
|
<strong>What happens next:</strong>
|
||||||
|
</p>
|
||||||
|
<ol class="mb-0 ps-3" style="font-size:.8rem; color:#555;">
|
||||||
|
<li>A username is automatically generated from the email address.</li>
|
||||||
|
<li>An invitation email is sent to the customer with a secure 72-hour link.</li>
|
||||||
|
<li>The customer clicks the link and sets their own password.</li>
|
||||||
|
<li>The account becomes fully active and they can log in immediately.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,14 +5,14 @@
|
|||||||
<div class="row mb-4 align-items-center">
|
<div class="row mb-4 align-items-center">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h2>
|
<h2>
|
||||||
<i class="bi bi-person-badge"></i> {{ customer.username }}
|
<i class="bi bi-person-badge"></i> {{ customer.display_name }}
|
||||||
{% if not customer.active %}
|
{% if not customer.active %}
|
||||||
<span class="badge bg-secondary ms-2 fs-6">Disabled</span>
|
<span class="badge bg-secondary ms-2 fs-6">Disabled</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-success ms-2 fs-6">Active</span>
|
<span class="badge bg-success ms-2 fs-6">Active</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-muted mb-0 small">{{ customer.email }}</p>
|
<p class="text-muted mb-0 small">{{ customer.email }}{% if customer.full_name %} · @{{ customer.username }}{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto d-flex gap-2">
|
<div class="col-auto d-flex gap-2">
|
||||||
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}"
|
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}"
|
||||||
@@ -46,6 +46,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<dl class="row mb-0 small">
|
<dl class="row mb-0 small">
|
||||||
|
<dt class="col-5 text-muted">Full Name</dt>
|
||||||
|
<dd class="col-7">{{ customer.full_name or '—' }}</dd>
|
||||||
<dt class="col-5 text-muted">Username</dt>
|
<dt class="col-5 text-muted">Username</dt>
|
||||||
<dd class="col-7">{{ customer.username }}</dd>
|
<dd class="col-7">{{ customer.username }}</dd>
|
||||||
<dt class="col-5 text-muted">Email</dt>
|
<dt class="col-5 text-muted">Email</dt>
|
||||||
@@ -56,6 +58,14 @@
|
|||||||
{{ 'Active' if customer.active else 'Disabled' }}
|
{{ 'Active' if customer.active else 'Disabled' }}
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt class="col-5 text-muted">Password</dt>
|
||||||
|
<dd class="col-7">
|
||||||
|
{% if customer.password_set %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Set</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>Pending setup</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
<dt class="col-5 text-muted">Created</dt>
|
<dt class="col-5 text-muted">Created</dt>
|
||||||
<dd class="col-7">{{ customer.created_at.strftime('%Y-%m-%d') }}</dd>
|
<dd class="col-7">{{ customer.created_at.strftime('%Y-%m-%d') }}</dd>
|
||||||
<dt class="col-5 text-muted">Assignments</dt>
|
<dt class="col-5 text-muted">Assignments</dt>
|
||||||
@@ -63,6 +73,17 @@
|
|||||||
<dt class="col-5 text-muted">Facilities</dt>
|
<dt class="col-5 text-muted">Facilities</dt>
|
||||||
<dd class="col-7">{{ facilities|length }}</dd>
|
<dd class="col-7">{{ facilities|length }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
{% if not customer.password_set %}
|
||||||
|
<hr class="my-3">
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('customers.resend_invite', customer_id=customer.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-outline-primary btn-sm w-100"
|
||||||
|
onclick="return confirm('Resend invitation email to {{ customer.email }}?')">
|
||||||
|
<i class="bi bi-send me-1"></i>Resend Invitation Email
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Set Your 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 Your Password</h4>
|
||||||
|
<p>Welcome, {{ user.display_name }}. Choose a secure password to activate 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" id="setPasswordForm" 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 %}
|
||||||
|
|
||||||
|
{# Strength bar #}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{# Requirements checklist #}
|
||||||
|
<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>Set 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');
|
||||||
|
var submitBtn = document.getElementById('submitBtn');
|
||||||
|
|
||||||
|
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 score = 0;
|
||||||
|
var hasLen = pw.length >= 8;
|
||||||
|
var hasUpper = /[A-Z]/.test(pw);
|
||||||
|
var hasNum = /[0-9]/.test(pw);
|
||||||
|
if (hasLen) score++;
|
||||||
|
if (hasUpper) score++;
|
||||||
|
if (hasNum) score++;
|
||||||
|
if (pw.length >= 12) score++;
|
||||||
|
|
||||||
|
markReq(reqLen, hasLen);
|
||||||
|
markReq(reqUpper, hasUpper);
|
||||||
|
markReq(reqNum, hasNum);
|
||||||
|
|
||||||
|
var pct = score * 25;
|
||||||
|
var color = score <= 1 ? 'danger' : score === 2 ? 'warning' : score === 3 ? 'info' : 'success';
|
||||||
|
bar.style.width = pct + '%';
|
||||||
|
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>
|
||||||
@@ -227,3 +227,26 @@ class CustomerUserForm(FlaskForm):
|
|||||||
"""Password is required when creating a new account."""
|
"""Password is required when creating a new account."""
|
||||||
if self._user is None and not field.data:
|
if self._user is None and not field.data:
|
||||||
raise ValidationError('Password is required for new accounts.')
|
raise ValidationError('Password is required for new accounts.')
|
||||||
|
|
||||||
|
class CustomerInviteForm(FlaskForm):
|
||||||
|
"""Simplified form for creating a customer account via email invitation.
|
||||||
|
|
||||||
|
Only Full Name and Email are required — no username or password.
|
||||||
|
The username is auto-generated from the email address.
|
||||||
|
The customer sets their own password via the emailed link.
|
||||||
|
"""
|
||||||
|
full_name = StringField('Full Name', validators=[DataRequired(), Length(max=150)])
|
||||||
|
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
|
||||||
|
|
||||||
|
def validate_email(self, field):
|
||||||
|
from app.models.user import User
|
||||||
|
if User.query.filter_by(email=field.data).first():
|
||||||
|
raise ValidationError('An account with this email address already exists.')
|
||||||
|
|
||||||
|
|
||||||
|
class SetPasswordForm(FlaskForm):
|
||||||
|
"""Public form for customer to set their password via emailed link."""
|
||||||
|
password = PasswordField('Password', validators=[DataRequired(), Length(min=8, max=100)])
|
||||||
|
confirm_password = PasswordField('Confirm Password',
|
||||||
|
validators=[DataRequired(),
|
||||||
|
EqualTo('password', message='Passwords must match.')])
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Phase 10: Customer password-setup workflow
|
||||||
|
|
||||||
|
Adds three columns to the users table to support the
|
||||||
|
invitation-based customer account creation flow:
|
||||||
|
|
||||||
|
password_set — False until the customer completes set-password
|
||||||
|
set_password_token — one-time URL token (64-char hex, nullable)
|
||||||
|
set_password_token_expires — UTC expiry datetime (nullable)
|
||||||
|
|
||||||
|
Revision ID: phase10_customer_password_setup
|
||||||
|
Revises: phase9_user_full_name
|
||||||
|
Create Date: 2026-04-02
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'phase10_customer_password_setup'
|
||||||
|
down_revision = 'phase9_user_full_name'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
columns = [c['name'] for c in inspector.get_columns('users')]
|
||||||
|
|
||||||
|
if 'password_set' not in columns:
|
||||||
|
op.add_column('users',
|
||||||
|
sa.Column('password_set', sa.Boolean, nullable=False, server_default='1'))
|
||||||
|
|
||||||
|
if 'set_password_token' not in columns:
|
||||||
|
op.add_column('users',
|
||||||
|
sa.Column('set_password_token', sa.String(64), nullable=True))
|
||||||
|
|
||||||
|
if 'set_password_token_expires' not in columns:
|
||||||
|
op.add_column('users',
|
||||||
|
sa.Column('set_password_token_expires', sa.DateTime, nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column('users', 'set_password_token_expires')
|
||||||
|
op.drop_column('users', 'set_password_token')
|
||||||
|
op.drop_column('users', 'password_set')
|
||||||
Reference in New Issue
Block a user