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

This commit is contained in:
2026-04-27 14:56:41 -04:00
parent 19fe1d8328
commit c31f08dbcc
4 changed files with 78 additions and 143 deletions
+32 -82
View File
@@ -127,41 +127,58 @@ def index():
@login_required @login_required
@supervisor_required @supervisor_required
def create(): def create():
"""Create a customer account with admin-supplied credentials. """Create a customer account via email invitation.
Admin provides Full Name, Email, Username, and Password. Admin enters Full Name and Email only. A temporary username is
The account is immediately active — the customer can log in straight away. auto-generated from the email address. A one-time set-password link
A welcome email is sent so they know their account is ready. is emailed; the customer chooses their own username and password when
they click it. The account is activated on completion.
""" """
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 a temporary username from the email local part.
# The customer replaces this with their preferred username when
# they complete the set-password flow via the emailed link.
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 a random placeholder password (password_set=False
# blocks login until the customer completes the set-password flow).
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 = True, # credentials set by admin — account active immediately 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}; credentials_set_by_admin=True') f'role=customer; email={user.email}; invite_sent=True')
_send_welcome_email(user) _send_invite_email(user, token)
flash( flash(
f'Customer account created for {full_name}. ' f'Customer account created for {full_name}. '
f'They can now log in with username "{username}".', f'An invitation email has been sent to {email} with a link to set their username and password.',
'success' 'success'
) )
return redirect(url_for('customers.manage', customer_id=user.id)) return redirect(url_for('customers.manage', customer_id=user.id))
@@ -242,74 +259,6 @@ 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'])
@@ -355,16 +304,17 @@ def set_password(token):
form = SetPasswordForm() form = SetPasswordForm()
if form.validate_on_submit(): if form.validate_on_submit():
user.set_password(form.password.data) user.username = form.username.data.strip()
user.password_set = True user.password_set = True
user.set_password(form.password.data)
user.clear_set_password_token() user.clear_set_password_token()
db.session.commit() db.session.commit()
logger.info('CUSTOMERS | password_set | user=%s', user.username) logger.info('CUSTOMERS | password_set | user=%s', user.username)
log_action(ACTION_UPDATE, 'User', user.id, user.username, log_action(ACTION_UPDATE, 'User', user.id, user.username,
'customer completed password setup via invite link') 'customer chose username and password via invite link')
flash('Your password has been set successfully. You can now log in.', 'success') flash('Your account is ready. You can now log in.', 'success')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
return render_template('customers/set_password.html', form=form, user=user) return render_template('customers/set_password.html', form=form, user=user)
+10 -37
View File
@@ -14,8 +14,9 @@
<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 details and set their login credentials. Enter the customer's name and email address. An invitation email will be
The account will be active immediately. sent automatically with a secure link where they can choose their own
username and password. The account will be activated once they complete that step.
</p> </p>
<form method="POST"> <form method="POST">
@@ -30,50 +31,22 @@
{% endfor %} {% endfor %}
</div> </div>
<div class="mb-3"> <div class="mb-4">
{{ 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-person me-1"></i> <i class="bi bi-envelope me-1"></i>
Used to log in. Lowercase letters, numbers, dots, hyphens, and underscores only. An invitation email with an account setup link will be sent to this address.
</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-person-check me-1"></i>Create Account <i class="bi bi-send me-1"></i>Create &amp; Send Invitation
</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
@@ -90,9 +63,9 @@
<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>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 chooses their own username and 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>
+20 -4
View File
@@ -32,7 +32,7 @@
<div class="setup-card"> <div class="setup-card">
<div class="setup-header"> <div class="setup-header">
<h4><i class="bi bi-shield-lock me-2"></i>Set Your Password</h4> <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> <p>Welcome, {{ user.display_name }}. Choose your username and a secure password to activate your account.</p>
</div> </div>
<div class="setup-body"> <div class="setup-body">
@@ -49,14 +49,30 @@
<form method="POST" id="setPasswordForm" novalidate> <form method="POST" id="setPasswordForm" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="username" class="form-label fw-semibold">Choose a Username</label>
<input type="text"
id="username"
name="username"
class="form-control {{ 'is-invalid' if form.username.errors else '' }}"
autocomplete="username"
autofocus
maxlength="100">
{% for error in form.username.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div class="form-text" style="font-size:.78rem;">
3100 characters. You will use this to log in.
</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="password" class="form-label fw-semibold">New Password</label> <label for="password" class="form-label fw-semibold">New Password</label>
<input type="password" <input type="password"
id="password" id="password"
name="password" name="password"
class="form-control {{ 'is-invalid' if form.password.errors else '' }}" class="form-control {{ 'is-invalid' if form.password.errors else '' }}"
autocomplete="new-password" autocomplete="new-password">
autofocus>
{% for error in form.password.errors %} {% for error in form.password.errors %}
<div class="invalid-feedback">{{ error }}</div> <div class="invalid-feedback">{{ error }}</div>
{% endfor %} {% endfor %}
@@ -96,7 +112,7 @@
</div> </div>
<button type="submit" class="btn btn-primary w-100" id="submitBtn"> <button type="submit" class="btn btn-primary w-100" id="submitBtn">
<i class="bi bi-check2-circle me-1"></i>Set Password &amp; Log In <i class="bi bi-check2-circle me-1"></i>Activate Account &amp; Log In
</button> </button>
</form> </form>
</div> </div>
+14 -18
View File
@@ -228,33 +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):
"""Form for creating a customer account. """Simplified form for creating a customer account via email invitation.
Admin enters Full Name, Email, Username, and Password on behalf of the Admin enters Full Name and Email only. A username is auto-generated
customer. The account is immediately active — no emailed setup link from the email address. The customer sets their own username and
is required, though an invitation email is still sent so the customer password via the emailed link.
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):
if User.query.filter_by(email=field.data.strip().lower()).first(): if User.query.filter_by(email=field.data.strip().lower()).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 choose their username and password via emailed link."""
password = PasswordField('Password', validators=[DataRequired(), Length(min=8, max=100)]) username = StringField('Choose a Username', validators=[DataRequired(), Length(min=3, max=100)])
password = PasswordField('Password', validators=[DataRequired(), Length(min=8, max=100)])
confirm_password = PasswordField('Confirm Password', confirm_password = PasswordField('Confirm Password',
validators=[DataRequired(), validators=[DataRequired(),
EqualTo('password', message='Passwords must match.')]) EqualTo('password', message='Passwords must match.')])
def validate_username(self, field):
existing = User.query.filter_by(username=field.data.strip()).first()
if existing:
raise ValidationError('This username is already taken. Please choose another.')