diff --git a/app/models/user.py b/app/models/user.py index a1a982c..ba4da91 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -22,6 +22,14 @@ class User(UserMixin, db.Model): created_at = db.Column(db.DateTime, default=now_eastern) 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 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 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): return f'' diff --git a/app/routes/auth.py b/app/routes/auth.py index b233481..f6aeae4 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -42,6 +42,13 @@ def login(): if not user.active: flash('Your account has been disabled. Please contact an administrator.', 'danger') 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) # Use validated next URL — never redirect blindly to request.args['next'] next_page = _safe_next(request.args.get('next')) diff --git a/app/routes/customers.py b/app/routes/customers.py index 951ea5b..e24b164 100644 --- a/app/routes/customers.py +++ b/app/routes/customers.py @@ -19,7 +19,7 @@ from app import db from app.models.user import User from app.models.project import Project, CustomerAssignment 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.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE 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']) @login_required @admin_required 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(): + 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( - username = form.username.data, - full_name = form.full_name.data.strip() or None, - email = form.email.data, - role = 'customer', - active = True, + username = username, + full_name = full_name, + email = email, + role = 'customer', + active = True, + password_set = False, ) - user.set_password(form.password.data) + user.set_password(secrets.token_hex(32)) db.session.add(user) + db.session.flush() + + token = user.generate_set_password_token(expires_hours=72) 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) log_action(ACTION_CREATE, 'User', user.id, user.username, - f'role=customer; email={user.email}; created_via=customer_mgmt') - flash(f'Customer account "{user.username}" created successfully.', 'success') + f'role=customer; email={user.email}; invite_sent=True') + + _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 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(""" + + +

Welcome to the Janitorial QC System

+

Hi {{ name }},

+

An account has been created for you on the Janitorial QC (JQC) portal. + To get started, please set your password using the button below.

+

+ + Set My Password + +

+

+ This link expires in 72 hours. If you did not expect this email, + you can safely ignore it. +

+

+ Or copy this URL:
+ {{ link }} +

+
+

Janitorial QC System — do not reply.

+ +""", 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('//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/', 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 ───────────────────────────────────────────────────────────── @bp.route('//edit', methods=['GET', 'POST']) diff --git a/app/templates/customers/invite.html b/app/templates/customers/invite.html new file mode 100644 index 0000000..ef4d52e --- /dev/null +++ b/app/templates/customers/invite.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}Create Customer Account{% endblock %} + +{% block content %} +
+
+ +
+
+

+ Create Customer Account +

+
+
+ +

+ 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. +

+ +
+ {{ form.hidden_tag() }} + +
+ {{ 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 %} +
{{ error }}
+ {% endfor %} +
+ +
+ {{ 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 %} +
{{ error }}
+ {% endfor %} +
+ + An invitation email with a password setup link will be sent to this address. +
+
+ +
+ + + Cancel + +
+
+
+
+ +
+
+

+ + What happens next: +

+
    +
  1. A username is automatically generated from the email address.
  2. +
  3. An invitation email is sent to the customer with a secure 72-hour link.
  4. +
  5. The customer clicks the link and sets their own password.
  6. +
  7. The account becomes fully active and they can log in immediately.
  8. +
+
+
+ +
+
+{% endblock %} diff --git a/app/templates/customers/manage.html b/app/templates/customers/manage.html index a5488d5..bd96d7a 100644 --- a/app/templates/customers/manage.html +++ b/app/templates/customers/manage.html @@ -5,14 +5,14 @@

- {{ customer.username }} + {{ customer.display_name }} {% if not customer.active %} Disabled {% else %} Active {% endif %}

-

{{ customer.email }}

+

{{ customer.email }}{% if customer.full_name %} · @{{ customer.username }}{% endif %}

diff --git a/app/templates/customers/set_password.html b/app/templates/customers/set_password.html new file mode 100644 index 0000000..ffb2fab --- /dev/null +++ b/app/templates/customers/set_password.html @@ -0,0 +1,163 @@ + + + + + + Set Your Password — Janitorial QC + + + + + + +
+
+

Set Your Password

+

Welcome, {{ user.display_name }}. Choose a secure password to activate 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 %} + + {# Strength bar #} +
+
+
+
+
+ + {# Requirements checklist #} +
+ + 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 b16588d..bb3b228 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -226,4 +226,27 @@ class CustomerUserForm(FlaskForm): def validate_password(self, field): """Password is required when creating a new account.""" if self._user is None and not field.data: - raise ValidationError('Password is required for new accounts.') \ No newline at end of file + 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.')]) diff --git a/migrations/versions/phase10_customer_password_setup.py b/migrations/versions/phase10_customer_password_setup.py new file mode 100644 index 0000000..1a34b4b --- /dev/null +++ b/migrations/versions/phase10_customer_password_setup.py @@ -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')