From c31f08dbcc0c54537d3be746dc97c8b11854bc75 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 27 Apr 2026 14:56:41 -0400 Subject: [PATCH] 04/27 Updated customer invitation allow customer to create username & password 3 --- app/routes/customers.py | 114 ++++++---------------- app/templates/customers/invite.html | 47 ++------- app/templates/customers/set_password.html | 26 ++++- app/utils/forms.py | 34 +++---- 4 files changed, 78 insertions(+), 143 deletions(-) diff --git a/app/routes/customers.py b/app/routes/customers.py index b3b3b1a..07de082 100644 --- a/app/routes/customers.py +++ b/app/routes/customers.py @@ -127,41 +127,58 @@ def index(): @login_required @supervisor_required 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. - The account is immediately active — the customer can log in straight away. - A welcome email is sent so they know their account is ready. + Admin enters Full Name and Email only. A temporary username is + auto-generated from the email address. A one-time set-password link + is emailed; the customer chooses their own username and password when + they click it. The account is activated on completion. """ 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 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( username = username, full_name = full_name, email = email, role = 'customer', 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.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}; credentials_set_by_admin=True') + f'role=customer; email={user.email}; invite_sent=True') - _send_welcome_email(user) + _send_invite_email(user, token) flash( 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' ) 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() -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(""" - - -

Your JQC Account is Ready

-

Hi {{ name }},

-

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

-

- - Log In Now - -

-
-

- Janitorial QC System — automated notification. Do not reply to this email. -

- -""", 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('//resend-invite', methods=['POST']) @@ -355,16 +304,17 @@ def set_password(token): form = SetPasswordForm() if form.validate_on_submit(): - user.set_password(form.password.data) + user.username = form.username.data.strip() user.password_set = True + user.set_password(form.password.data) 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') + '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 render_template('customers/set_password.html', form=form, user=user) diff --git a/app/templates/customers/invite.html b/app/templates/customers/invite.html index 0bd7aa0..71f9d09 100644 --- a/app/templates/customers/invite.html +++ b/app/templates/customers/invite.html @@ -14,8 +14,9 @@

- Enter the customer's details and set their login credentials. - The account will be active immediately. + Enter the customer's name and email address. An invitation email will be + 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.

@@ -30,50 +31,22 @@ {% 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 %} -
- -
- {{ 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 %} -
{{ error }}
- {% endfor %}
- - 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.
-
- {{ 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 %} -
{{ error }}
- {% endfor %} -
Minimum 8 characters.
-
- -
- {{ 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 %} -
{{ error }}
- {% endfor %} -
-
diff --git a/app/templates/customers/set_password.html b/app/templates/customers/set_password.html index ffb2fab..ae99ed9 100644 --- a/app/templates/customers/set_password.html +++ b/app/templates/customers/set_password.html @@ -32,7 +32,7 @@

Set Your Password

-

Welcome, {{ user.display_name }}. Choose a secure password to activate your account.

+

Welcome, {{ user.display_name }}. Choose your username and a secure password to activate your account.

@@ -49,14 +49,30 @@ +
+ + + {% for error in form.username.errors %} +
{{ error }}
+ {% endfor %} +
+ 3–100 characters. You will use this to log in. +
+
+
+ autocomplete="new-password"> {% for error in form.password.errors %}
{{ error }}
{% endfor %} @@ -96,7 +112,7 @@
@@ -160,4 +176,4 @@ })(); - + \ No newline at end of file diff --git a/app/utils/forms.py b/app/utils/forms.py index 4be12b1..78f61b2 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -228,33 +228,29 @@ class CustomerUserForm(FlaskForm): raise ValidationError('Password is required for new accounts.') 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 - 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. + Admin enters Full Name and Email only. A username is auto-generated + from the email address. The customer sets their own username and + password via the emailed link. """ - 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.')]) + full_name = StringField('Full Name', validators=[DataRequired(), Length(max=150)]) + email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)]) def validate_email(self, field): 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.""" - password = PasswordField('Password', validators=[DataRequired(), Length(min=8, max=100)]) + """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)]) confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), - EqualTo('password', message='Passwords must match.')]) \ No newline at end of file + 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.') \ No newline at end of file