04/27 Updated customer invitation allow customer to create username & password

This commit is contained in:
2026-04-27 14:44:02 -04:00
parent b18b23a796
commit bd05384a59
3 changed files with 134 additions and 44 deletions
+79 -24
View File
@@ -127,54 +127,41 @@ def index():
@login_required @login_required
@supervisor_required @supervisor_required
def create(): def create():
"""Create a customer account via email invitation. """Create a customer account with admin-supplied credentials.
Only Full Name and Email are required. A username is auto-generated Admin provides Full Name, Email, Username, and Password.
from the email address. A one-time set-password link is emailed to The account is immediately active — the customer can log in straight away.
the customer; they cannot log in until that link is used. A welcome email is sent so they know their account is ready.
""" """
form = CustomerInviteForm() form = CustomerInviteForm()
if form.validate_on_submit(): if form.validate_on_submit():
import re, secrets
full_name = form.full_name.data.strip() full_name = form.full_name.data.strip()
email = form.email.data.strip().lower() email = form.email.data.strip().lower()
username = form.username.data.strip()
# 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 = username, username = username,
full_name = full_name, full_name = full_name,
email = email, email = email,
role = 'customer', role = 'customer',
active = True, active = True,
password_set = False, password_set = True, # credentials set by admin — account active immediately
) )
user.set_password(secrets.token_hex(32)) user.set_password(form.password.data)
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 | invite | admin=%s new_customer=%s email=%s', logger.info('CUSTOMERS | create | 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}; invite_sent=True') f'role=customer; email={user.email}; credentials_set_by_admin=True')
_send_invite_email(user, token) _send_welcome_email(user)
flash( flash(
f'Customer account created for {full_name}. ' f'Customer account created for {full_name}. '
f'An invitation email has been sent to {email} with a link to set their password.', f'They can now log in with username '{username}'.',
'success' 'success'
) )
return redirect(url_for('customers.manage', customer_id=user.id)) return redirect(url_for('customers.manage', customer_id=user.id))
@@ -255,6 +242,74 @@ def _send_invite_email(user, token):
threading.Thread(target=_send, daemon=True).start() threading.Thread(target=_send, daemon=True).start()
def _send_welcome_email(user):
"""Send a welcome email to a newly created customer whose credentials were set by admin."""
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('WELCOME EMAIL SKIPPED | no MAIL_SERVER | user=%s', user.username)
return
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
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;">Your JQC Account is Ready</h2>
<p>Hi {{ name }},</p>
<p>
An account has been created for you on the Janitorial QC portal.
You can log in immediately using the credentials provided to you by your administrator.
</p>
<p>
<a href="{{ login_url }}"
style="background:#0d6efd;color:#fff;padding:10px 20px;
text-decoration:none;border-radius:4px;display:inline-block;">
Log In Now
</a>
</p>
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
<p style="font-size:12px;color:#888;">
Janitorial QC System — automated notification. Do not reply to this email.
</p>
</body>
</html>""", name=user.display_name, login_url=f'{base_url}/auth/login')
text_body = (
f'Hi {user.display_name},\n\n'
f'An account has been created for you on the Janitorial QC portal.\n'
f'You can log in immediately using the credentials provided by your administrator.\n\n'
f'Login: {base_url}/auth/login\n\nJanitorial QC System'
)
msg = Message(
subject = '[JQC] Your account is ready',
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('WELCOME EMAIL SENT | to=%s | user=%s', user.email, user.username)
except Exception as exc:
logger.error('WELCOME EMAIL FAILED | to=%s | error=%s', user.email, exc)
threading.Thread(target=_send, daemon=True).start()
# ── Resend invitation email ─────────────────────────────────────────────────── # ── Resend invitation email ───────────────────────────────────────────────────
@bp.route('/<int:customer_id>/resend-invite', methods=['POST']) @bp.route('/<int:customer_id>/resend-invite', methods=['POST'])
+38 -12
View File
@@ -14,9 +14,8 @@
<div class="card-body"> <div class="card-body">
<p class="text-muted mb-4" style="font-size:.9rem;"> <p class="text-muted mb-4" style="font-size:.9rem;">
Enter the customer's name and email address. An invitation email will be Enter the customer's details and set their login credentials.
sent automatically with a secure link to set their password. The account will be active immediately.
The account will be activated once they complete that step.
</p> </p>
<form method="POST"> <form method="POST">
@@ -31,22 +30,50 @@
{% endfor %} {% endfor %}
</div> </div>
<div class="mb-4"> <div class="mb-3">
{{ form.email.label(class="form-label fw-semibold") }} {{ form.email.label(class="form-label fw-semibold") }}
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""), {{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
placeholder="jane@example.com") }} placeholder="jane@example.com") }}
{% for error in form.email.errors %} {% for error in form.email.errors %}
<div class="invalid-feedback">{{ error }}</div> <div class="invalid-feedback">{{ error }}</div>
{% endfor %} {% endfor %}
</div>
<div class="mb-3">
{{ form.username.label(class="form-label fw-semibold") }}
{{ form.username(class="form-control" + (" is-invalid" if form.username.errors else ""),
placeholder="e.g. jsmith") }}
{% for error in form.username.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div class="form-text"> <div class="form-text">
<i class="bi bi-envelope me-1"></i> <i class="bi bi-person me-1"></i>
An invitation email with a password setup link will be sent to this address. Used to log in. Lowercase letters, numbers, dots, hyphens, and underscores only.
</div> </div>
</div> </div>
<div class="mb-3">
{{ form.password.label(class="form-label fw-semibold") }}
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else ""),
autocomplete="new-password") }}
{% for error in form.password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div class="form-text">Minimum 8 characters.</div>
</div>
<div class="mb-4">
{{ form.confirm_password.label(class="form-label fw-semibold") }}
{{ form.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>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<i class="bi bi-send me-1"></i>Create &amp; Send Invitation <i class="bi bi-person-check me-1"></i>Create Account
</button> </button>
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>Cancel <i class="bi bi-x-circle me-1"></i>Cancel
@@ -63,14 +90,13 @@
<strong>What happens next:</strong> <strong>What happens next:</strong>
</p> </p>
<ol class="mb-0 ps-3" style="font-size:.8rem; color:#555;"> <ol class="mb-0 ps-3" style="font-size:.8rem; color:#555;">
<li>A username is automatically generated from the email address.</li> <li>The account is created immediately and the customer can log in right away.</li>
<li>An invitation email is sent to the customer with a secure 72-hour link.</li> <li>A welcome email is sent to the customer with a link to the login page.</li>
<li>The customer clicks the link and sets their own password.</li> <li>Assign the customer to Contracts or Facilities from their profile page.</li>
<li>The account becomes fully active and they can log in immediately.</li>
</ol> </ol>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+17 -8
View File
@@ -228,20 +228,29 @@ class CustomerUserForm(FlaskForm):
raise ValidationError('Password is required for new accounts.') raise ValidationError('Password is required for new accounts.')
class CustomerInviteForm(FlaskForm): class CustomerInviteForm(FlaskForm):
"""Simplified form for creating a customer account via email invitation. """Form for creating a customer account.
Only Full Name and Email are required — no username or password. Admin enters Full Name, Email, Username, and Password on behalf of the
The username is auto-generated from the email address. customer. The account is immediately active — no emailed setup link
The customer sets their own password via the emailed link. is required, though an invitation email is still sent so the customer
knows their credentials have been created.
""" """
full_name = StringField('Full Name', validators=[DataRequired(), Length(max=150)]) full_name = StringField('Full Name', validators=[DataRequired(), Length(max=150)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)]) email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)])
password = PasswordField('Password', validators=[DataRequired(), Length(min=8, max=100)])
confirm_password = PasswordField('Confirm Password',
validators=[DataRequired(),
EqualTo('password', message='Passwords must match.')])
def validate_email(self, field): def validate_email(self, field):
from app.models.user import User if User.query.filter_by(email=field.data.strip().lower()).first():
if User.query.filter_by(email=field.data).first():
raise ValidationError('An account with this email address already exists.') raise ValidationError('An account with this email address already exists.')
def validate_username(self, field):
if User.query.filter_by(username=field.data.strip()).first():
raise ValidationError('This username is already taken.')
class SetPasswordForm(FlaskForm): class SetPasswordForm(FlaskForm):
"""Public form for customer to set their password via emailed link.""" """Public form for customer to set their password via emailed link."""