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
+2 -1
View File
@@ -21,7 +21,8 @@
"Bash(python -c \"import ast;[ast.parse\\(open\\(f,encoding='utf-8'\\).read\\(\\)\\) for f in ['app/enrollment/mailer.py','app/enrollment/schema.py']];print\\('parses OK'\\)\")", "Bash(python -c \"import ast;[ast.parse\\(open\\(f,encoding='utf-8'\\).read\\(\\)\\) for f in ['app/enrollment/mailer.py','app/enrollment/schema.py']];print\\('parses OK'\\)\")",
"Bash(python -c \"import ast;ast.parse\\(open\\('app/enrollment/mailer.py',encoding='utf-8'\\).read\\(\\)\\);print\\('parses OK'\\)\")", "Bash(python -c \"import ast;ast.parse\\(open\\('app/enrollment/mailer.py',encoding='utf-8'\\).read\\(\\)\\);print\\('parses OK'\\)\")",
"Bash(grep -n \"\\\\\\\\\\\\\\\\n\" app/enrollment/mailer.py)", "Bash(grep -n \"\\\\\\\\\\\\\\\\n\" app/enrollment/mailer.py)",
"Bash(git stash *)" "Bash(git stash *)",
"Bash(python -c \"import ast;ast.parse\\(open\\('app/routes/auth.py',encoding='utf-8'\\).read\\(\\)\\);print\\('parses OK'\\)\")"
] ]
} }
} }
+2
View File
@@ -203,6 +203,8 @@ users: id, username (unique, indexed), full_name, email (unique, indexed),
The two roles are distinguished by **display only**. `User.INSPECTOR_ROLES = ('inspector', 'external_inspector')` and the `User.is_inspector` property are the single definition — **every** capability/scoping check tests `is_inspector`, never `role == 'inspector'` (rule 87). `User.is_external_inspector` and `User.role_label` (backed by the `ROLE_LABELS` map) drive the "External" badges: users list, dashboard **Inspector Activity**, **Inspector Performance** report (HTML + the Excel export, where the name cell is suffixed `(External)` rather than gaining a column so the index-based cell styling stays correct), and every assignee dropdown (`(External)` suffix — issues create/update, issue-list quick-assign, inspection flag-issue). The two roles are distinguished by **display only**. `User.INSPECTOR_ROLES = ('inspector', 'external_inspector')` and the `User.is_inspector` property are the single definition — **every** capability/scoping check tests `is_inspector`, never `role == 'inspector'` (rule 87). `User.is_external_inspector` and `User.role_label` (backed by the `ROLE_LABELS` map) drive the "External" badges: users list, dashboard **Inspector Activity**, **Inspector Performance** report (HTML + the Excel export, where the name cell is suffixed `(External)` rather than gaining a column so the index-based cell styling stays correct), and every assignee dropdown (`(External)` suffix — issues create/update, issue-list quick-assign, inspection flag-issue).
**Invited, not provisioned (Aug 2026).** An external inspector works outside the business, so an admin never sets their password. Creating one at `/auth/users/new` follows the customer invitation flow instead: the account is stored with `password_set=False` and a random placeholder hash, a 72-hour `set_password_token` is minted, and `customers._send_invite_email()` (reused unchanged — its copy already fits any invited account) sends a link to **`/customers/set-password/<token>`**, where they choose their own **username and password**. `login()` refuses `password_set=False` until they finish. `POST /auth/users/<id>/resend-invite` (admin-only) mints a fresh token and re-sends — without it a bounced or expired invitation would leave the account permanently unusable. The users list shows an **"Invite pending"** badge and the resend button while `password_set` is false. Every other role is unaffected: they are still created with an admin-set password, and `create_user()` now **rejects a blank password** for them rather than storing the hash of an empty string.
Assignable (rule 80 set becomes `director`/`inspector`/`external_inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown), included in Inspector Performance and Inspector Activity, and has **mobile-API access**`external_inspector` is in the `_ALLOWED_ROLES` of every `app/api/*` module and falls into the inspector branch of every scoping check there. It gets its **own Notification Matrix column** (`external_inspector`), whose defaults mirror the Inspector column (see §11). Assignable (rule 80 set becomes `director`/`inspector`/`external_inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown), included in Inspector Performance and Inspector Activity, and has **mobile-API access**`external_inspector` is in the `_ALLOWED_ROLES` of every `app/api/*` module and falls into the inspector branch of every scoping check there. It gets its **own Notification Matrix column** (`external_inspector`), whose defaults mirror the Inspector column (see §11).
**`auditor` (Phase 40):** A staff role with the **same access as `project_manager`** (it is included in `@project_manager_required` and everywhere `project_manager` is checked) **plus full issue-management powers** — create, assign, quick-assign, handler/vendor triage, request-verification, and verify/bulk-verify/verification-queue (via the new `@issue_manager_required` decorator). **Auditor does NOT get issue deletion** (that stays admin/director via `@supervisor_required`), nor any other admin/director-only area PM lacks (users, audit trail, notification matrix, customers, templates). Auditors are **assignable** as an issue/inspection assignee; **admin was removed** from the assignable set at the same time (assignee dropdowns are now `director`/`inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown). The issue-update route defensively keeps any pre-existing out-of-set assignee (e.g. a legacy admin assignment) in the dropdown so saving never silently unassigns. Auditor **has mobile-API access** — it is included in the `_ALLOWED_ROLES` set of every `app/api/*` module (comments, inspections, issues, photos, scheduled, stats, templates), so the iPad app accepts auditor logins. In every API endpoint that scopes by role, auditor falls into the non-inspector/non-customer (privileged) branch — org-wide data, same as admin/director/PM. **`auditor` (Phase 40):** A staff role with the **same access as `project_manager`** (it is included in `@project_manager_required` and everywhere `project_manager` is checked) **plus full issue-management powers** — create, assign, quick-assign, handler/vendor triage, request-verification, and verify/bulk-verify/verification-queue (via the new `@issue_manager_required` decorator). **Auditor does NOT get issue deletion** (that stays admin/director via `@supervisor_required`), nor any other admin/director-only area PM lacks (users, audit trail, notification matrix, customers, templates). Auditors are **assignable** as an issue/inspection assignee; **admin was removed** from the assignable set at the same time (assignee dropdowns are now `director`/`inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown). The issue-update route defensively keeps any pre-existing out-of-set assignee (e.g. a legacy admin assignment) in the dropdown so saving never silently unassigns. Auditor **has mobile-API access** — it is included in the `_ALLOWED_ROLES` set of every `app/api/*` module (comments, inspections, issues, photos, scheduled, stats, templates), so the iPad app accepts auditor logins. In every API endpoint that scopes by role, auditor falls into the non-inspector/non-customer (privileged) branch — org-wide data, same as admin/director/PM.
+87 -9
View File
@@ -66,7 +66,7 @@ def profile():
form = ProfileForm(user=current_user, obj=current_user) form = ProfileForm(user=current_user, obj=current_user)
if form.validate_on_submit(): 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() current_user.email = form.email.data.strip().lower()
if form.new_password.data: if form.new_password.data:
@@ -282,20 +282,63 @@ def create_user():
if form.validate_on_submit(): if form.validate_on_submit():
role = 'inspector' if director_editing else form.role.data 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( user = User(
username=form.username.data, 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(), 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.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() 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, log_action(ACTION_CREATE, 'User', user.id, user.username,
f'role={user.role}; email={user.email}') f'role={user.role}; email={user.email}; invite_sent={invite}')
flash(f'User {user.username} created successfully.', 'success')
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 redirect(url_for('auth.list_users'))
return render_template('auth/user_form.html', form=form, title='Create User', 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(): if form.validate_on_submit():
user.username = form.username.data 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() user.email = form.email.data.strip().lower()
if not director_editing: if not director_editing:
@@ -339,6 +382,41 @@ def edit_user(user_id):
title='Edit User', director_editing=director_editing) 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']) @bp.route('/users/<int:user_id>/assign-contracts', methods=['GET', 'POST'])
@login_required @login_required
@admin_required @admin_required
+37 -1
View File
@@ -79,7 +79,20 @@
</div> </div>
</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"> <div class="col-md-6 mb-3">
{{ form.password.label(class="form-label") }} {{ form.password.label(class="form-label") }}
{{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }} {{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }}
@@ -127,4 +140,27 @@
</div> </div>
</div> </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 %} {% endblock %}
+21 -3
View File
@@ -55,10 +55,17 @@
</td> </td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td> <td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
<td> <td>
{% if user.active %} {% if not user.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Disabled</span> <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 %} {% endif %}
</td> </td>
<td> <td>
@@ -71,6 +78,17 @@
<i class="bi bi-briefcase"></i> <i class="bi bi-briefcase"></i>
</a> </a>
{% endif %} {% 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 %} {% if user.id != current_user.id %}
<form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}" class="d-inline"> <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() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">