04/27 Updated customer invitation allow customer to create username & password 3
This commit is contained in:
+32
-82
@@ -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)
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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;">
|
||||||
|
3–100 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 & Log In
|
<i class="bi bi-check2-circle me-1"></i>Activate Account & Log In
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-15
@@ -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."""
|
||||||
|
username = StringField('Choose a Username', validators=[DataRequired(), Length(min=3, max=100)])
|
||||||
password = PasswordField('Password', validators=[DataRequired(), Length(min=8, 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.')
|
||||||
Reference in New Issue
Block a user