"""Command-line user management for the Work Package Suite. There is no local password (D15) and no local account creation from here anymore (D16, T10.4) — accounts are created by signing in through Okta, which JIT- provisions a row at the lowest-privilege role (see server/okta_auth.py, server/app.py's okta_callback(), wave-10.md T10.3). This tool's job is narrower now: change the role on an account that already exists, and do routine account maintenance from a shell on the server. That narrower job is still how the very first admin gets named (D16): have that person sign in through Okta once — they land as project_user — then promote them from here. Promoting an existing row, rather than creating one blind, matters because it never has to guess the exact string Okta will send as the identity claim; a hand-typed username that doesn't match it exactly would just produce a second, orphaned account instead of the one you meant to promote. 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 promote alice --role admin python -m server.manage_users list python -m server.manage_users disable bob python -m server.manage_users enable bob """ import argparse import sys from .db import SessionLocal, Base, engine from . import models, auth def cmd_promote(args) -> None: role = args.role # 'user' is the pre-roles spelling of 'project_user', accepted here so a # documented one-liner from before this rework keeps working. 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)}") with SessionLocal() as db: u = auth.find_user(db, args.username) if not u: sys.exit( f"No user named '{args.username}'. This promotes an existing account, it " f"doesn't create one — they need to sign in through Okta at least once first." ) u.role = role db.commit() print(f"{u.username} is now {auth.ROLE_LABELS.get(role, role)}.") 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. Accounts appear here once someone signs in through Okta.") 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 _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) pr = sub.add_parser("promote", help="change an existing account's role (e.g. name the first admin)") pr.add_argument("username") pr.add_argument("--role", required=True, choices=list(auth.ROLES) + ["user"], help="permissions role ('user' is the legacy name for project_user)") sub.add_parser("list", help="list all accounts") dp = sub.add_parser("disable", help="disable an account (blocks sign-in)") 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 == "promote": cmd_promote(args) elif args.cmd == "list": cmd_list(args) elif args.cmd == "disable": _set_active(args.username, False) elif args.cmd == "enable": _set_active(args.username, True) if __name__ == "__main__": main()