Aug 7 - Update External Inspector invitation
This commit is contained in:
@@ -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\\('app/enrollment/mailer.py',encoding='utf-8'\\).read\\(\\)\\);print\\('parses OK'\\)\")",
|
||||
"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'\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
**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).
|
||||
|
||||
**`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
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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() }}">
|
||||
|
||||
Reference in New Issue
Block a user