diff --git a/backend/main.py b/backend/main.py index 5305c37..cad523a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -432,6 +432,60 @@ async def project_portal_preview(project_id: str, db: Session = Depends(get_db)) return resp +@app.post("/projects/{project_id}/portal-link") +async def project_portal_link_create(project_id: str, request: Request, db: Session = Depends(get_db)): + """Mint a fresh shareable client link for this project's client. Returns the + full /portal/enter/ URL (shown once). Operator-only (internal app).""" + from backend.models import Project + from backend.portal_auth import ensure_project_client, mint_link_token + project = db.query(Project).filter_by(id=project_id).first() + if not project: + return JSONResponse(status_code=404, content={"detail": "Project not found"}) + client = ensure_project_client(project, db) + raw = mint_link_token(client, db, label="shared link") + url = str(request.base_url).rstrip("/") + f"/portal/enter/{raw}" + return {"url": url, "client_name": client.name} + + +@app.get("/projects/{project_id}/portal-links") +async def project_portal_links_list(project_id: str, db: Session = Depends(get_db)): + """List active (non-revoked) shareable links for this project's client.""" + from backend.models import Project, ClientAccessToken, Client + project = db.query(Project).filter_by(id=project_id).first() + if not project or not project.client_id: + return {"client_name": None, "links": []} + client = db.query(Client).filter_by(id=project.client_id).first() + toks = (db.query(ClientAccessToken) + .filter_by(client_id=project.client_id, revoked_at=None) + .order_by(ClientAccessToken.created_at.desc()).all()) + return { + "client_name": client.name if client else None, + "links": [{ + "id": t.id, "label": t.label, + "created_at": t.created_at.isoformat() if t.created_at else None, + "last_used_at": t.last_used_at.isoformat() if t.last_used_at else None, + } for t in toks], + } + + +@app.post("/projects/{project_id}/portal-link/{token_id}/revoke") +async def project_portal_link_revoke(project_id: str, token_id: str, db: Session = Depends(get_db)): + """Revoke one shareable link (scoped to this project's client). Kills the link + and any live session minted from it on the next request.""" + from datetime import datetime as _dt + from backend.models import Project, ClientAccessToken + project = db.query(Project).filter_by(id=project_id).first() + if not project or not project.client_id: + return JSONResponse(status_code=404, content={"detail": "Not found"}) + tok = db.query(ClientAccessToken).filter_by(id=token_id, client_id=project.client_id).first() + if not tok: + return JSONResponse(status_code=404, content={"detail": "Link not found"}) + if not tok.revoked_at: + tok.revoked_at = _dt.utcnow() + db.commit() + return {"ok": True} + + @app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse) async def nrl_detail_page( request: Request, diff --git a/backend/portal_auth.py b/backend/portal_auth.py index ac52ae7..cb4dc8c 100644 --- a/backend/portal_auth.py +++ b/backend/portal_auth.py @@ -120,15 +120,10 @@ def resolve_token(raw_token: str, db: Session): return tok, client -def provision_preview_session(project, db) -> str: - """Testing convenience (operator-side): ensure a Client + access token exist for - a project so an operator can preview the client portal without the CLI. Returns - the token id to seal into a session cookie. - - Reuses the project's linked client if it has one; otherwise creates/uses a - per-project 'preview-' client. Only sets project.client_id when it's unset, - so previewing never clobbers a real client link. The token's raw secret is - discarded (preview rides the cookie, not a magic link).""" +def ensure_project_client(project, db) -> Client: + """Find or create the Client for a project. Reuses the project's linked client + if it has one; otherwise creates/uses a per-project 'preview-' client and + sets project.client_id (only when unset, so it never clobbers a real link).""" client = None if project.client_id: client = db.query(Client).filter_by(id=project.client_id, active=True).first() @@ -143,6 +138,25 @@ def provision_preview_session(project, db) -> str: db.flush() if not project.client_id: project.client_id = client.id + return client + + +def mint_link_token(client, db, label=None) -> str: + """Mint a fresh access token for a client and return the RAW secret (caller + builds the /portal/enter/ URL and shows it once). Only the hash is stored.""" + raw = secrets.token_urlsafe(32) + db.add(ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id, + token_hash=hash_token(raw), label=label)) + db.commit() + return raw + + +def provision_preview_session(project, db) -> str: + """Operator preview shortcut: ensure a Client + access token exist for a project + and return a token id to seal into a session cookie (no shared link). Reuses an + existing token so repeat previews don't accumulate clutter; the raw secret is + discarded (preview rides the cookie).""" + client = ensure_project_client(project, db) tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first() if tok is None: tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id, diff --git a/templates/projects/detail.html b/templates/projects/detail.html index 1542c2f..94675ed 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -18,16 +18,27 @@ Project - - - - - - - View client portal - + +
+ + + + + + + View client portal + +
@@ -2085,5 +2096,102 @@ document.addEventListener('DOMContentLoaded', function() { } }); + + + + + + {% endblock %}