diff --git a/app/routes/customers.py b/app/routes/customers.py index 680da8a..72605e1 100644 --- a/app/routes/customers.py +++ b/app/routes/customers.py @@ -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(""" + + +

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']) diff --git a/app/templates/customers/invite.html b/app/templates/customers/invite.html index ef4d52e..0bd7aa0 100644 --- a/app/templates/customers/invite.html +++ b/app/templates/customers/invite.html @@ -14,9 +14,8 @@

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

@@ -31,22 +30,50 @@ {% 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 %}
- - 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.
+
+ {{ 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 %} +
+
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/app/utils/forms.py b/app/utils/forms.py index 5218e08..4be12b1 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -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."""