From 26b4b1e7e402348ae5dfb528c4e1c53c1ae38d7b Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 10 Jun 2026 21:43:28 +0000 Subject: [PATCH] =?UTF-8?q?feat(portal):=20M1=20admin=20CLI=20=E2=80=94=20?= =?UTF-8?q?create=20client,=20link=20projects,=20mint/revoke=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend/portal_admin.py (run in-container): create-client, link-project (by id/ number/name -> sets Project.client_id), mint-link (prints the full magic URL once, stores only the hash), list, revoke. PORTAL_BASE_URL controls the printed link base. Co-Authored-By: Claude Opus 4.8 --- backend/portal_admin.py | 165 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 backend/portal_admin.py diff --git a/backend/portal_admin.py b/backend/portal_admin.py new file mode 100644 index 0000000..c69e4db --- /dev/null +++ b/backend/portal_admin.py @@ -0,0 +1,165 @@ +#!/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()