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\\(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'\\)\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+85
-7
@@ -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,19 +282,62 @@ 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,
|
||||||
)
|
)
|
||||||
|
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)
|
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}')
|
||||||
|
|
||||||
|
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')
|
flash(f'User {user.username} created successfully.', 'success')
|
||||||
return redirect(url_for('auth.list_users'))
|
return redirect(url_for('auth.list_users'))
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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() }}">
|
||||||
|
|||||||
Reference in New Issue
Block a user