diff --git a/backend/operator_admin.py b/backend/operator_admin.py new file mode 100644 index 0000000..f5860fb --- /dev/null +++ b/backend/operator_admin.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Operator-account admin CLI — the bootstrap and the break-glass. Run inside the +terra-view container against the live DB. Temp/raw passwords are printed ONCE; only +hashes persist. + + # first superadmin (before any UI is reachable) — prompts for a password, or --generate + python3 backend/operator_admin.py create-superadmin --email you@x.com --name "Brian" + + # a parent's account — generates a temp password, must-change on first login + python3 backend/operator_admin.py create-user --email dad@x.com --name "Dad" --role admin + + python3 backend/operator_admin.py reset-password --email dad@x.com + python3 backend/operator_admin.py list + python3 backend/operator_admin.py disable --email dad@x.com + python3 backend/operator_admin.py enable --email dad@x.com +""" +import os +import sys +import getpass +import argparse +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend.database import SessionLocal +from backend.models import OperatorUser +from backend.operator_auth import ( + create_operator, reset_operator_password, set_operator_active, _norm_email, +) + + +def _get(db, email): + u = db.query(OperatorUser).filter_by(email=_norm_email(email)).first() + if not u: + sys.exit(f"No operator with email '{email}'.") + return u + + +def cmd_create_superadmin(email, name, password=None, generate=False): + db = SessionLocal() + try: + if password is None and not generate: + password = getpass.getpass("Password for new superadmin: ") + if not password or len(password) < 8: + sys.exit("Password must be at least 8 characters.") + user, raw = create_operator(db, email, name, "superadmin", + password=None if generate else password) + if generate: + print(f"✓ Superadmin {user.email} created. Temp password (shown once): {raw}") + else: + print(f"✓ Superadmin {user.email} created.") + except ValueError as e: + sys.exit(str(e)) + finally: + db.close() + + +def cmd_create_user(email, name, role="admin"): + db = SessionLocal() + try: + user, raw = create_operator(db, email, name, role) + print(f"✓ {role} {user.email} created. Temp password (shown once): {raw}") + print(" They'll be required to change it on first login.") + except ValueError as e: + sys.exit(str(e)) + finally: + db.close() + + +def cmd_reset_password(email): + db = SessionLocal() + try: + user = _get(db, email) + raw = reset_operator_password(db, user) + print(f"✓ Reset {user.email}. Temp password (shown once): {raw}") + finally: + db.close() + + +def cmd_set_active(email, active): + db = SessionLocal() + try: + user = _get(db, email) + set_operator_active(db, user, active) + print(f"✓ {user.email} {'enabled' if active else 'disabled'}.") + finally: + db.close() + + +def cmd_list(): + db = SessionLocal() + try: + users = db.query(OperatorUser).order_by(OperatorUser.display_name).all() + if not users: + print("No operators yet. Run create-superadmin first.") + return + for u in users: + locked = " [LOCKED]" if (u.locked_until and u.locked_until > datetime.utcnow()) else "" + state = "active" if u.active else "DISABLED" + last = u.last_login_at.strftime("%Y-%m-%d %H:%M") if u.last_login_at else "never" + print(f" {u.display_name:<12} {u.email:<28} {u.role:<11} {state}{locked} last: {last}") + finally: + db.close() + + +def main(): + ap = argparse.ArgumentParser(description="Operator-account admin") + sub = ap.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("create-superadmin") + p.add_argument("--email", required=True); p.add_argument("--name", required=True) + p.add_argument("--generate", action="store_true", help="generate a temp password instead of prompting") + p.set_defaults(fn=lambda a: cmd_create_superadmin(a.email, a.name, generate=a.generate)) + + p = sub.add_parser("create-user") + p.add_argument("--email", required=True); p.add_argument("--name", required=True) + p.add_argument("--role", default="admin", choices=["admin", "superadmin"]) + p.set_defaults(fn=lambda a: cmd_create_user(a.email, a.name, a.role)) + + p = sub.add_parser("reset-password") + p.add_argument("--email", required=True) + p.set_defaults(fn=lambda a: cmd_reset_password(a.email)) + + p = sub.add_parser("disable"); p.add_argument("--email", required=True) + p.set_defaults(fn=lambda a: cmd_set_active(a.email, False)) + + p = sub.add_parser("enable"); p.add_argument("--email", required=True) + p.set_defaults(fn=lambda a: cmd_set_active(a.email, True)) + + p = sub.add_parser("list"); p.set_defaults(fn=lambda a: cmd_list()) + + args = ap.parse_args() + args.fn(args) + + +if __name__ == "__main__": + main() diff --git a/tests/test_operator_admin_cli.py b/tests/test_operator_admin_cli.py new file mode 100644 index 0000000..2e9d4e4 --- /dev/null +++ b/tests/test_operator_admin_cli.py @@ -0,0 +1,44 @@ +# tests/test_operator_admin_cli.py +from sqlalchemy.orm import sessionmaker +from backend.models import OperatorUser +from backend.auth_passwords import verify_password +import backend.operator_admin as cli + + +def _maker(db_session): + return sessionmaker(bind=db_session.get_bind(), autocommit=False, autoflush=False) + + +def test_seed_superadmin(db_session, monkeypatch): + monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False) + cli.cmd_create_superadmin(email="brian@x.com", name="Brian", password="chosen-pw-1") + u = db_session.query(OperatorUser).filter_by(email="brian@x.com").first() + assert u.role == "superadmin" + assert u.must_change_password is False + assert verify_password("chosen-pw-1", u.password_hash) + + +def test_create_user_generates_temp(db_session, monkeypatch, capsys): + monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False) + cli.cmd_create_user(email="dad@x.com", name="Dad", role="admin") + u = db_session.query(OperatorUser).filter_by(email="dad@x.com").first() + assert u.role == "admin" and u.must_change_password is True + assert "dad@x.com" in capsys.readouterr().out # prints the temp once + + +def test_reset_password_cli(db_session, monkeypatch): + monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False) + cli.cmd_create_user(email="r@x.com", name="R", role="admin") + before = db_session.query(OperatorUser).filter_by(email="r@x.com").first().password_hash + cli.cmd_reset_password(email="r@x.com") + after = db_session.query(OperatorUser).filter_by(email="r@x.com").first().password_hash + assert before != after + + +def test_disable_enable_cli(db_session, monkeypatch): + monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False) + cli.cmd_create_user(email="d@x.com", name="D", role="admin") + cli.cmd_set_active(email="d@x.com", active=False) + assert db_session.query(OperatorUser).filter_by(email="d@x.com").first().active is False + cli.cmd_set_active(email="d@x.com", active=True) + assert db_session.query(OperatorUser).filter_by(email="d@x.com").first().active is True