Files
Project-SDE-WP-Suite/server/manage_users.py
n.siegfried 4ace2afb1c 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>
2026-08-05 17:36:14 -07:00

152 lines
5.3 KiB
Python

"""Command-line user management for the Work Package Suite.
Use this to create the FIRST admin account (the /api/auth/users endpoint needs an
existing admin, so you have to bootstrap one here), and for occasional account
maintenance from a shell on the server.
Run from the PROJECT ROOT (same place you run uvicorn), so the package imports
and .env resolve the same way the API does:
python -m server.manage_users create-admin alice --name "Alice Smith"
python -m server.manage_users create bob --role user --name "Bob Jones"
python -m server.manage_users list
python -m server.manage_users reset-password alice
python -m server.manage_users disable bob
python -m server.manage_users enable bob
If --password is omitted you'll be prompted (input is hidden). Passwords must be
at least 8 characters.
"""
import argparse
import getpass
import sys
import uuid
from .db import SessionLocal, Base, engine
from . import models, auth
def _gen_id() -> str:
return f"user_{uuid.uuid4().hex[:12]}"
def _prompt_password(provided: str | None, username: str = "") -> str:
pw = provided
if not pw:
pw = getpass.getpass("New password: ")
confirm = getpass.getpass("Confirm password: ")
if pw != confirm:
sys.exit("Passwords do not match.")
problem = auth.password_problem(pw, username)
if problem:
sys.exit(problem)
return pw
def cmd_create(args, role: str | None = None) -> None:
role = role or args.role
# 'user' is the pre-roles spelling of 'project_user' and is still accepted so the
# documented one-liners keep working; anything else has to be a current role.
if role == "user":
role = auth.ROLE_PROJECT_USER
if role not in auth.ROLES:
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
pw = _prompt_password(getattr(args, "password", None), args.username)
with SessionLocal() as db:
if auth.find_user(db, args.username):
sys.exit(f"A user named '{args.username}' already exists.")
u = models.User(
id=_gen_id(),
username=args.username.strip(),
full_name=(args.name or "").strip(),
email=(args.email or "").strip(),
password_hash=auth.hash_password(pw),
role=role,
)
db.add(u)
db.commit()
print(f"Created {role}: {u.username} (id={u.id})")
def cmd_list(args) -> None:
with SessionLocal() as db:
rows = db.query(models.User).order_by(models.User.username).all()
if not rows:
print("No users yet. Create one with: create-admin <username>")
return
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
for u in rows:
print(f"{u.username:<24}{auth.normalize_role(u.role):<20}"
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
def cmd_reset_password(args) -> None:
pw = _prompt_password(getattr(args, "password", None), args.username)
with SessionLocal() as db:
u = auth.find_user(db, args.username)
if not u:
sys.exit(f"No user named '{args.username}'.")
u.password_hash = auth.hash_password(pw)
db.commit()
print(f"Password reset for {u.username}.")
def _set_active(username: str, active: bool) -> None:
with SessionLocal() as db:
u = auth.find_user(db, username)
if not u:
sys.exit(f"No user named '{username}'.")
u.is_active = active
db.commit()
print(f"{u.username} is now {'enabled' if active else 'disabled'}.")
def main() -> None:
# Ensure the users table exists even on a fresh database.
Base.metadata.create_all(bind=engine)
p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management")
sub = p.add_subparsers(dest="cmd", required=True)
def add_create(name, help_):
sp = sub.add_parser(name, help=help_)
sp.add_argument("username")
sp.add_argument("--password", help="set non-interactively (otherwise prompted)")
sp.add_argument("--name", default="", help="full name")
sp.add_argument("--email", default="")
return sp
add_create("create-admin", "create an admin account")
c = add_create("create", "create an account")
c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER,
help="permissions role ('user' is the legacy name for project_user)")
sub.add_parser("list", help="list all accounts")
rp = sub.add_parser("reset-password", help="reset a user's password")
rp.add_argument("username")
rp.add_argument("--password", help="set non-interactively (otherwise prompted)")
dp = sub.add_parser("disable", help="disable an account (blocks login)")
dp.add_argument("username")
ep = sub.add_parser("enable", help="re-enable an account")
ep.add_argument("username")
args = p.parse_args()
if args.cmd == "create-admin":
cmd_create(args, role="admin")
elif args.cmd == "create":
cmd_create(args)
elif args.cmd == "list":
cmd_list(args)
elif args.cmd == "reset-password":
cmd_reset_password(args)
elif args.cmd == "disable":
_set_active(args.username, False)
elif args.cmd == "enable":
_set_active(args.username, True)
if __name__ == "__main__":
main()