Move user administration to its own page; add Project Super User

User accounts lived in the Admin Console, which is admins-only. Project admins
need to create the accounts on their own jobs without an app admin on the phone,
so accounts move to a new User Directory page and a new role carries the right.

server/auth.py, server/app.py
  New permissions role `project_super_user`, between admin and project_admin:
  everything a project admin may do, plus user administration SCOPED to the
  projects they hold the role on. Four limits make it safe to hand out, all
  enforced server-side:

    * Scope comes from projects, not the job title. It resolves per membership
      (managed_project_ids), so an ordinary account can hold it on one job via
      ProjectMember.role, and a super user demoted on one job administers
      nobody there. No projects, no authority.
    * Account-level changes (password, disable, rename, permissions, delete)
      require EXCLUSIVE scope: refused when the target is also on a project the
      caller does not administer, because those changes are global. The
      directory renders such rows read-only with the reason.
    * No admin or super-user targets, and neither role can be granted by a
      super user -- that is the line that stops it becoming app-wide control.
    * PUT .../projects rebuilds only the caller's own slice; memberships on
      projects they do not administer are left untouched. A payload that simply
      omits them must not cut someone off a job the caller cannot see.

  Creating requires naming at least one of your own projects: an account with
  none would be one the creator instantly cannot manage.

  /api/auth/users is now scoped rather than admin-only, and carries a per-row
  `manageable` verdict plus the reason. Non-managers get a contact card only --
  a project user has no business reading colleagues' login history. New
  /api/auth/user-scope tells the page what it may offer. Administrative
  password resets are now audited; they were the one account change that left
  no trace. Settings, feature flags and the auto-add rule stay admin-only.

  While here: one definition of "is a user manager", derived from the managed
  set. An account-role-only version disagreed with the scoped one and locked
  per-project super users out of routes they were entitled to.

html/users.html, html/users.js
  The directory: three renderings from one page -- admin (everything), super
  user (controls per row, read-only where scope is shared), everyone else (a
  read-only directory of the people on their own projects).

html/console.css, html/console-util.js
  Extracted from admin.html/admin.js so both console pages share them. A
  divergent jsq() is an XSS and a divergent role list offers permissions the
  server refuses, so neither may exist twice.

html/wp-sidenav.{js,css}
  Global nav drawer, role-gated, carrying ?project= across links. Mounted on
  the field view (which had no way to anywhere) plus both console pages.

No migration: users.role is already String(20) and the new value fits.

Verified: 93 scope/gate tests, 29 live HTTP tests through the real dependency
stack, 33 static JS checks. Not verified in a browser -- no JS engine on this
machine -- so users.html and field.html want one manual load.

server/smoketest.py still fails with 401s. Pre-existing: it has no login code,
so auth_gate refuses it. Confirmed unchanged by stashing this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-05 17:36:14 -07:00
parent 3cccdf1c4b
commit 4ace2afb1c
19 changed files with 1769 additions and 643 deletions

View File

@@ -20,11 +20,21 @@ 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_super_user
everything a project_admin may do, plus USER ADMINISTRATION
scoped to the projects they hold the role on: they create and
manage the accounts on their own jobs without an app admin
having to do it for them. They cannot reach app settings, and
they cannot create or alter an admin / super-user account.
• 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.
The user-administration SCOPE of a super user is worked out in server/app.py
(`managed_project_ids`, `manage_user_problem`), because it depends on project
membership rows — this module only decides which roles carry the power at all.
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
@@ -56,14 +66,21 @@ RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
# ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin"
ROLE_PROJECT_SUPER = "project_super_user"
ROLE_PROJECT_ADMIN = "project_admin"
ROLE_PROJECT_USER = "project_user"
ROLES = (ROLE_ADMIN, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
# Ordered most- to least-privileged; the console renders dropdowns in this order.
ROLES = (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
ROLE_LABELS = {
ROLE_ADMIN: "Administrator",
ROLE_PROJECT_SUPER: "Project Super User",
ROLE_PROJECT_ADMIN: "Project Admin",
ROLE_PROJECT_USER: "Project User",
}
# Roles that may be held ON A SINGLE PROJECT via ProjectMember.role, so someone can
# run the users on one job and be an ordinary member of the next. '' means "inherit
# the account's own role" and is always allowed alongside these.
PROJECT_SCOPED_ROLES = (ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_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 = (
@@ -90,9 +107,19 @@ def is_admin(user: "models.User") -> bool:
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)
"""True for the roles allowed to delete work packages and change a completed
SOP. A super user is a project admin with user administration on top, so it is
included here — never enumerate the two roles by hand."""
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN)
# NOTE: "may this account administer users?" is deliberately NOT answered here. The
# super-user role can be held per project (ProjectMember.role), so the question needs
# membership rows to answer and lives in app.py — `is_user_manager` /
# `require_user_manager` / `managed_project_ids`. An account-role-only version of the
# same question used to exist here and silently disagreed with the scoped one, which
# locked per-project super users out of the routes they were entitled to.
# Password policy (shared by the API and the CLI).
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
@@ -297,6 +324,8 @@ def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.Us
return user
# ── account helpers (shared by routes and the CLI) ──────────────────────────────
def find_user(db: Session, username: str) -> Optional["models.User"]:
"""Look up by username, case-insensitively (also matches on email)."""