diff --git a/.claude/settings.json b/.claude/settings.json index f20ea4d..d8a3d47 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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'\\)\")" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 676165b..e0f5651 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/`**, where they choose their own **username and password**. `login()` refuses `password_set=False` until they finish. `POST /auth/users//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. diff --git a/app/routes/auth.py b/app/routes/auth.py index 15444f8..677475a 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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//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//assign-contracts', methods=['GET', 'POST']) @login_required @admin_required diff --git a/app/templates/auth/user_form.html b/app/templates/auth/user_form.html index 1d0658d..38ff652 100644 --- a/app/templates/auth/user_form.html +++ b/app/templates/auth/user_form.html @@ -79,7 +79,20 @@ -
+ {# 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. #} +
+ + This account will be invited by email. + 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. +
+ +
{{ form.password.label(class="form-label") }} {{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }} @@ -127,4 +140,27 @@
+ + {% endblock %} \ No newline at end of file diff --git a/app/templates/auth/users.html b/app/templates/auth/users.html index d7b5907..c7229d8 100644 --- a/app/templates/auth/users.html +++ b/app/templates/auth/users.html @@ -55,10 +55,17 @@ {{ user.created_at.strftime('%Y-%m-%d') }} - {% if user.active %} - Active - {% else %} + {% if not user.active %} Disabled + {% elif not user.password_set %} + {# phase51 — invited but has not chosen a password yet; + the login route refuses them until they do. #} + + Invite pending + + {% else %} + Active {% endif %} @@ -71,6 +78,17 @@ {% endif %} + {% if not user.password_set %} +
+ + +
+ {% endif %} {% if user.id != current_user.id %}