#!/usr/bin/env python3 """ Client-portal admin CLI (M1). Operator tooling — run inside the terra-view container against the live DB. The raw magic-link token is shown ONCE on mint; only its hash is stored. # create a client python3 backend/portal_admin.py create-client --name "Myler Co" --slug myler [--email dave@x.com] # attach a project to a client (sets Project.client_id) — by id, number, or name python3 backend/portal_admin.py link-project --slug myler --project-id python3 backend/portal_admin.py link-project --slug myler --project-number 2567-23 python3 backend/portal_admin.py link-project --slug myler --project-name "RKM Hall" # mint a magic access link (FULL URL PRINTED ONCE — copy it now) python3 backend/portal_admin.py mint-link --slug myler [--label "Dave's link"] # list clients, their projects, and active links python3 backend/portal_admin.py list # revoke a link (stops the link AND any live session it minted) python3 backend/portal_admin.py revoke --token-id The printed URL base comes from PORTAL_BASE_URL (default http://localhost:8001). """ import os import sys import uuid import secrets import argparse from datetime import datetime from backend.database import SessionLocal from backend.models import Client, ClientAccessToken, Project from backend.portal_auth import hash_token PORTAL_BASE_URL = os.getenv("PORTAL_BASE_URL", "http://localhost:8001").rstrip("/") def _get_client(db, slug): c = db.query(Client).filter_by(slug=slug).first() if not c: sys.exit(f"No client with slug '{slug}'. Create it first.") return c def create_client(args): db = SessionLocal() try: if db.query(Client).filter_by(slug=args.slug).first(): sys.exit(f"A client with slug '{args.slug}' already exists.") c = Client(id=str(uuid.uuid4()), name=args.name, slug=args.slug, contact_email=args.email, active=True) db.add(c) db.commit() print(f"✓ Created client '{c.name}' (slug={c.slug}, id={c.id})") print(" Next: link-project, then mint-link.") finally: db.close() def link_project(args): db = SessionLocal() try: c = _get_client(db, args.slug) q = db.query(Project) if args.project_id: p = q.filter_by(id=args.project_id).first() elif args.project_number: p = q.filter_by(project_number=args.project_number).first() elif args.project_name: p = q.filter_by(name=args.project_name).first() else: sys.exit("Specify --project-id, --project-number, or --project-name.") if not p: sys.exit("Project not found.") p.client_id = c.id db.commit() print(f"✓ Linked project '{p.name}' (id={p.id}) -> client '{c.name}'") finally: db.close() def mint_link(args): db = SessionLocal() try: c = _get_client(db, args.slug) raw = secrets.token_urlsafe(32) tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=c.id, token_hash=hash_token(raw), label=args.label) db.add(tok) db.commit() print(f"✓ Minted access link for '{c.name}'" f"{f' ({args.label})' if args.label else ''} — token id {tok.id}") print("\n COPY THIS NOW (shown only once):\n") print(f" {PORTAL_BASE_URL}/portal/enter/{raw}\n") finally: db.close() def revoke(args): db = SessionLocal() try: tok = db.query(ClientAccessToken).filter_by(id=args.token_id).first() if not tok: sys.exit("No token with that id.") if tok.revoked_at: print("○ Already revoked.") return tok.revoked_at = datetime.utcnow() db.commit() print(f"✓ Revoked token {tok.id} — the link and any live sessions it minted are dead.") finally: db.close() def list_all(args): db = SessionLocal() try: clients = db.query(Client).order_by(Client.name).all() if not clients: print("No clients yet.") return for c in clients: state = "" if c.active else " [INACTIVE]" print(f"\n● {c.name} (slug={c.slug}){state}") projs = db.query(Project).filter_by(client_id=c.id).all() print(" projects: " + (", ".join(p.name for p in projs) or "(none linked)")) toks = db.query(ClientAccessToken).filter_by(client_id=c.id).all() if not toks: print(" links: (none — run mint-link)") for t in toks: status = "revoked" if t.revoked_at else "active" last = t.last_used_at.strftime("%Y-%m-%d %H:%M") if t.last_used_at else "never used" print(f" link {t.id} [{status}] {t.label or ''} (last: {last})") print() finally: db.close() def main(): ap = argparse.ArgumentParser(description="Client-portal admin (M1)") sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("create-client"); p.add_argument("--name", required=True) p.add_argument("--slug", required=True); p.add_argument("--email"); p.set_defaults(fn=create_client) p = sub.add_parser("link-project"); p.add_argument("--slug", required=True) p.add_argument("--project-id"); p.add_argument("--project-number"); p.add_argument("--project-name") p.set_defaults(fn=link_project) p = sub.add_parser("mint-link"); p.add_argument("--slug", required=True) p.add_argument("--label"); p.set_defaults(fn=mint_link) p = sub.add_parser("revoke"); p.add_argument("--token-id", required=True); p.set_defaults(fn=revoke) p = sub.add_parser("list"); p.set_defaults(fn=list_all) args = ap.parse_args() args.fn(args) if __name__ == "__main__": main()