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
@supervisor_required
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
from the email address. A one-time set-password link is emailed to
the customer; they cannot log in until that link is used.
Admin provides Full Name, Email, Username, and Password.
The account is immediately active — the customer can log in straight away.
A welcome email is sent so they know their account is ready.
"""
form = CustomerInviteForm()
if form.validate_on_submit():
import re, secrets
full_name = form.full_name.data.strip()
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(
username = username,
full_name = full_name,
email = email,
role = 'customer',
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.flush()
token = user.generate_set_password_token(expires_hours=72)
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)
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(
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'
)
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()
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 ───────────────────────────────────────────────────
@bp.route('/<int:customer_id>/resend-invite', methods=['POST'])
+38 -12
View File
@@ -14,9 +14,8 @@
<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.
Enter the customer's details and set their login credentials.
The account will be active immediately.
</p>
<form method="POST">
@@ -31,22 +30,50 @@
{% endfor %}
</div>
<div class="mb-4">
<div class="mb-3">
{{ 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>
<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">
<i class="bi bi-envelope me-1"></i>
An invitation email with a password setup link will be sent to this address.
<i class="bi bi-person me-1"></i>
Used to log in. Lowercase letters, numbers, dots, hyphens, and underscores only.
</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">
<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>
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>Cancel
@@ -63,14 +90,13 @@
<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>
<li>The account is created immediately and the customer can log in right away.</li>
<li>A welcome email is sent to the customer with a link to the login page.</li>
<li>Assign the customer to Contracts or Facilities from their profile page.</li>
</ol>
</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.')
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.
The username is auto-generated from the email address.
The customer sets their own password via the emailed link.
Admin enters Full Name, Email, Username, and Password on behalf of the
customer. The account is immediately active — no emailed setup 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)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
full_name = StringField('Full Name', validators=[DataRequired(), Length(max=150)])
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):
from app.models.user import User
if User.query.filter_by(email=field.data).first():
if User.query.filter_by(email=field.data.strip().lower()).first():
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):
"""Public form for customer to set their password via emailed link."""