Wave 1: permissions roles, account-backed SOP team, critical constraints, password reset, BIM flag

Acts on the site comments from 8/3 plus the follow-ups. Foundation work first —
four of the comments all needed the project team to resolve to real user accounts.

Permissions vs project role (new)
- User.role is now the PERMISSIONS role: admin | project_admin | project_user.
  project_admin may delete work packages, change a SOP after it is complete, and
  delete a project; project_user may not (archiving a WP is still open to them).
  Enforced by require_project_admin() server-side; the UI only hides dead ends.
- New User.project_role holds the person's JOB FUNCTION on the project. It grants
  nothing — it feeds the SOP team pickers and notification routing.
- Admin console shows both columns and explains the difference. Migration rewrites
  the legacy role 'user' to 'project_user'.
- Deleting a project was previously open to any member and unaudited; it now needs
  project_admin and writes an audit event. ProjectData.remove no longer drops the
  project from the local cache when the server refuses.

SOP project team from user accounts
- PM/APM/CM/QM and additional team members are pickers over the project's members,
  storing the account id next to the display name. A name from an older SOP with no
  matching account is kept and flagged rather than dropped.
- The WP Creator lists the SOP team first in the Owner picker, and a new package
  defaults to whoever is creating it.

Critical constraints
- SOP constraints carry a Critical flag; buildConstraints() now copies the whole
  definition through to the package (it previously reduced them to names, losing
  description too), and critical rows are marked in the WP form. The email on
  reopen-after-release is wave 3.

Password reset by email
- login.html gains Forgot password and a set-a-new-password view, offered only when
  the server reports email is actually configured.
- Single-use signed token (AUTH_RESET_MINUTES, default 60) bound to token_version,
  sent immediately rather than through the notifications outbox so a reset link is
  never persisted. Identical response for unknown accounts; per-account send
  cooldown; a completed reset clears any login lockout.
- Session and reset tokens are no longer interchangeable.

BIM kill-switch
- New admin Features card with bim_enabled, OFF by default. The SOP creator hides
  the BIM section and the Creator treats every package as install-only while it is
  off; a SOP that already has BIM keeps its data untouched.

Verified with two throwaway-database test scripts: 44 checks on the permissions
matrix and token handling, 22 on the reset flow end-to-end against a local SMTP
sink (real message captured, link extracted and used). Front-end files parse-checked
in headless Chrome. Not yet exercised in a browser against a real login.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 14:48:59 -07:00
parent 1d004cab75
commit 79b0e955b4
16 changed files with 1106 additions and 128 deletions

View File

@@ -16,7 +16,19 @@ Security model:
set; if it is missing we fall back to a random per-process key (which logs a
warning and invalidates every session on restart) so dev still works.
Roles: 'admin' (may manage users) and 'user'.
Permissions roles (`User.role`) — distinct from a person's job function on the
project, which lives in `User.project_role` and grants nothing:
• admin application administrator: user administration, app settings,
and implicit access to every project.
• project_admin within their assigned projects: may delete work packages,
modify a SOP after it has been completed, and delete projects.
• project_user normal member: creates and edits work packages, authors a SOP
up to completion. May NOT delete WPs or change a completed SOP.
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
to the account's address. It is single-use by construction — it embeds the user's
`token_version`, which is bumped when the password changes, so a used or
superseded link stops validating.
"""
import os
import secrets
@@ -39,6 +51,48 @@ COOKIE_NAME = "wp_session"
JWT_ALG = "HS256"
# How long a login lasts before the user must sign in again.
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
# How long an emailed password-reset link stays valid.
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
# ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin"
ROLE_PROJECT_ADMIN = "project_admin"
ROLE_PROJECT_USER = "project_user"
ROLES = (ROLE_ADMIN, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
ROLE_LABELS = {
ROLE_ADMIN: "Administrator",
ROLE_PROJECT_ADMIN: "Project Admin",
ROLE_PROJECT_USER: "Project User",
}
# Job functions offered in the admin console. Free text underneath, so a project
# can use a title that isn't on this list.
PROJECT_ROLES = (
"Project Manager", "Assistant Project Manager", "Construction Manager",
"Quality Manager", "Superintendent", "General Foreman", "Foreman",
"Planner / Scheduler", "BIM / VDC Coordinator", "Engineer",
"Safety (HSE)", "Warehouse / Materials", "Commissioning", "Field Technician",
)
def normalize_role(role: Optional[str]) -> str:
"""Map a stored/incoming role onto the current vocabulary.
Accounts created before permissions roles existed carry the legacy value
'user', which means exactly what 'project_user' means now."""
r = (role or "").strip()
if r == "user":
return ROLE_PROJECT_USER
return r if r in ROLES else ROLE_PROJECT_USER
def is_admin(user: "models.User") -> bool:
return normalize_role(user.role) == ROLE_ADMIN
def is_project_admin(user: "models.User") -> bool:
"""True for app admins and project admins — the two roles allowed to delete
work packages and change a completed SOP."""
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_ADMIN)
# Password policy (shared by the API and the CLI).
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
@@ -134,11 +188,43 @@ def create_token(user: "models.User") -> str:
def decode_token(token: str) -> Optional[dict]:
"""Return the token claims if the signature and expiry are valid, else None."""
"""Return the token claims if the signature and expiry are valid, else None.
Session cookies only — a token of any other type is rejected."""
try:
return jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
# A password-reset token must never be usable as a session cookie.
if claims.get("typ"):
return None
return claims
def create_reset_token(user: "models.User") -> str:
"""Short-lived, single-use token for an emailed password-reset link.
Single-use falls out of `ver`: completing a reset bumps the user's
token_version, so the link (and any older link) no longer validates."""
now = datetime.now(timezone.utc)
payload = {
"typ": "pwreset",
"sub": user.id,
"ver": user.token_version or 0,
"iat": now,
"exp": now + timedelta(minutes=RESET_MINUTES),
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
def decode_reset_token(token: str) -> Optional[dict]:
"""Claims for a valid, unexpired reset token, else None."""
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
if claims.get("typ") != "pwreset":
return None
return claims
# ── cookie helpers ────────────────────────────────────────────────────────────
@@ -206,7 +292,7 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models
def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User":
if user.role != "admin":
if not is_admin(user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user