1cf80ea7ea
`python3 backend/portal_admin.py` set sys.path[0] to backend/, hiding the `backend` package and breaking `from backend.database import ...`. Insert the project root on sys.path so the documented script invocation works. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
170 lines
6.0 KiB
Python
170 lines
6.0 KiB
Python
#!/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 <PID>
|
|
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 <TID>
|
|
|
|
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
|
|
|
|
# Allow `python3 backend/portal_admin.py ...` (which puts backend/ on sys.path[0],
|
|
# hiding the `backend` package) in addition to `python3 -m backend.portal_admin`.
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
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()
|