Aug 7 - Update External Inspector invitation

This commit is contained in:
2026-08-07 14:59:40 -04:00
parent c686d12e64
commit 9f32ea8286
5 changed files with 149 additions and 14 deletions
+87 -9
View File
@@ -66,7 +66,7 @@ def profile():
form = ProfileForm(user=current_user, obj=current_user)
if form.validate_on_submit():
current_user.full_name = form.full_name.data.strip() or None
current_user.full_name = (form.full_name.data or '').strip() or None
current_user.email = form.email.data.strip().lower()
if form.new_password.data:
@@ -282,20 +282,63 @@ def create_user():
if form.validate_on_submit():
role = 'inspector' if director_editing else form.role.data
# phase51 — an external inspector works for the customer or a third
# party, so we never set a password on their behalf. They are invited
# exactly like a customer: created with password_set=False (which the
# login route refuses until they finish), given a one-time token, and
# emailed a link to choose their own username and password.
invite = (role == 'external_inspector')
# UserForm.password is Optional() because the same form is used for
# EDIT, where blank means "keep current". On CREATE a blank password
# would otherwise store the hash of an empty string, so require one
# unless the account is being invited to choose their own.
if not invite and not form.password.data:
flash('Please set a password, or choose the External Inspector role '
'to send an invitation instead.', 'danger')
return render_template('auth/user_form.html', form=form, user=None,
title='Create User',
director_editing=director_editing)
user = User(
username=form.username.data,
full_name=form.full_name.data.strip() or None,
full_name=(form.full_name.data or '').strip() or None,
email=form.email.data.strip().lower(),
role=role
role=role,
password_set=not invite,
)
user.set_password(form.password.data)
if invite:
# A random unguessable placeholder — password_set=False already
# blocks login, but never leave an account holding a known or
# empty-string hash.
user.set_password(secrets.token_hex(32))
else:
user.set_password(form.password.data)
db.session.add(user)
db.session.flush() # need user.id before minting the token
token = user.generate_set_password_token(expires_hours=72) if invite else None
db.session.commit()
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s',
current_user.id, current_user.username, user.username, user.role)
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s invite=%s',
current_user.id, current_user.username, user.username,
user.role, invite)
log_action(ACTION_CREATE, 'User', user.id, user.username,
f'role={user.role}; email={user.email}')
flash(f'User {user.username} created successfully.', 'success')
f'role={user.role}; email={user.email}; invite_sent={invite}')
if invite:
# Reuses the customer invitation email — the copy ("an account has
# been created for you… set your password") is already correct for
# any invited account. Imported inside the function to keep the
# auth ↔ customers import graph acyclic.
from app.routes.customers import _send_invite_email
_send_invite_email(user, token, base_url=request.host_url)
flash(f'External inspector {user.display_name} created. An invitation '
f'email has been sent to {user.email} with a link to set their '
f'username and password.', 'success')
else:
flash(f'User {user.username} created successfully.', 'success')
return redirect(url_for('auth.list_users'))
return render_template('auth/user_form.html', form=form, title='Create User',
@@ -318,7 +361,7 @@ def edit_user(user_id):
if form.validate_on_submit():
user.username = form.username.data
user.full_name = form.full_name.data.strip() or None
user.full_name = (form.full_name.data or '').strip() or None
user.email = form.email.data.strip().lower()
if not director_editing:
@@ -339,6 +382,41 @@ def edit_user(user_id):
title='Edit User', director_editing=director_editing)
@bp.route('/users/<int:user_id>/resend-invite', methods=['POST'])
@login_required
@admin_required
def resend_invite(user_id):
"""Re-send the set-password invitation for an account still awaiting setup.
Without this an invitation that bounces, is deleted or expires leaves the
account permanently unusable — password_set=False blocks login and only a
valid token can clear it. Mirrors customers.resend_invite for staff-side
accounts (currently only external inspectors are ever invited this way).
"""
user = db.session.get(User, user_id)
if user is None:
abort(404)
if user.password_set:
flash(f'{user.display_name} has already completed their account setup.',
'info')
return redirect(url_for('auth.list_users'))
# A fresh token invalidates the previous link.
token = user.generate_set_password_token(expires_hours=72)
db.session.commit()
logger.info('AUTH | resend_invite | admin=%s user=%s',
current_user.username, user.username)
log_action(ACTION_UPDATE, 'User', user.id, user.username,
'invitation email resent')
from app.routes.customers import _send_invite_email
_send_invite_email(user, token, base_url=request.host_url)
flash(f'Invitation resent to {user.email}.', 'success')
return redirect(url_for('auth.list_users'))
@bp.route('/users/<int:user_id>/assign-contracts', methods=['GET', 'POST'])
@login_required
@admin_required
+37 -1
View File
@@ -79,7 +79,20 @@
</div>
</div>
<div class="row">
{# phase51 — an External Inspector is invited by email and chooses
their own username and password, so the admin never sets one.
The JS at the foot of this page swaps these two blocks when the
role changes; the server decides independently of the JS. #}
<div id="inviteNotice" class="alert alert-info d-none">
<i class="bi bi-envelope me-1"></i>
<strong>This account will be invited by email.</strong>
External inspectors work outside the business, so we do not set
a password for them. On save, an invitation is sent to the email
address above with a link to choose their own username and
password. The link is valid for 72 hours.
</div>
<div class="row" id="passwordFields">
<div class="col-md-6 mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }}
@@ -127,4 +140,27 @@
</div>
</div>
</div>
<script>
(function () {
'use strict';
var roleSel = document.getElementById('role');
var pwBlock = document.getElementById('passwordFields');
var notice = document.getElementById('inviteNotice');
if (!roleSel || !pwBlock || !notice) return; // director view has no role select
function sync() {
var invited = roleSel.value === 'external_inspector';
pwBlock.classList.toggle('d-none', invited);
notice.classList.toggle('d-none', !invited);
// Clear anything already typed so an invited account can never be created
// with an admin-chosen password sitting in the POST body.
if (invited) {
pwBlock.querySelectorAll('input').forEach(function (i) { i.value = ''; });
}
}
roleSel.addEventListener('change', sync);
sync();
})();
</script>
{% endblock %}
+21 -3
View File
@@ -55,10 +55,17 @@
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
<td>
{% if user.active %}
<span class="badge bg-success">Active</span>
{% else %}
{% if not user.active %}
<span class="badge bg-secondary">Disabled</span>
{% elif not user.password_set %}
{# phase51 — invited but has not chosen a password yet;
the login route refuses them until they do. #}
<span class="badge bg-warning text-dark"
title="Invitation sent — this person has not set their password yet">
<i class="bi bi-envelope"></i> Invite pending
</span>
{% else %}
<span class="badge bg-success">Active</span>
{% endif %}
</td>
<td>
@@ -71,6 +78,17 @@
<i class="bi bi-briefcase"></i>
</a>
{% endif %}
{% if not user.password_set %}
<form method="POST" action="{{ url_for('auth.resend_invite', user_id=user.id) }}"
class="d-inline"
onsubmit="return confirm('Resend the invitation email to {{ user.email }}? The previous link will stop working.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-warning"
title="Resend invitation email">
<i class="bi bi-envelope-arrow-up"></i>
</button>
</form>
{% endif %}
{% if user.id != current_user.id %}
<form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">