From 25a4a284331ac7e52b949706f3eb87f70556cdf6 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 15 Jun 2026 23:55:10 +0000 Subject: [PATCH] feat: operator portal-access endpoints (enable/password/disable/state) Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 57 +++++++++++++++++++++++++++++++ tests/test_portal_access_admin.py | 40 ++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 tests/test_portal_access_admin.py diff --git a/backend/main.py b/backend/main.py index 799d943..cbd9809 100644 --- a/backend/main.py +++ b/backend/main.py @@ -487,6 +487,63 @@ async def project_portal_link_revoke(project_id: str, token_id: str, db: Session return {"ok": True} +@app.get("/projects/{project_id}/portal-access") +async def project_portal_access_state(project_id: str, request: Request, db: Session = Depends(get_db)): + """Current portal-access state for the operator panel.""" + from backend.models import Project + p = db.query(Project).filter_by(id=project_id).first() + if not p: + return JSONResponse(status_code=404, content={"detail": "Project not found"}) + link_url = (str(request.base_url).rstrip("/") + f"/portal/p/{p.portal_link_token}") \ + if (p.portal_enabled and p.portal_link_token) else None + return {"enabled": bool(p.portal_enabled), "has_password": bool(p.portal_password_hash), + "link_url": link_url} + + +@app.post("/projects/{project_id}/portal-access/enable") +async def project_portal_access_enable(project_id: str, request: Request, db: Session = Depends(get_db)): + """Turn the portal on; mint a link token if one doesn't exist yet.""" + import secrets + from backend.models import Project + p = db.query(Project).filter_by(id=project_id).first() + if not p: + return JSONResponse(status_code=404, content={"detail": "Project not found"}) + if not p.portal_link_token: + p.portal_link_token = secrets.token_urlsafe(24) + p.portal_enabled = True + db.commit() + link_url = str(request.base_url).rstrip("/") + f"/portal/p/{p.portal_link_token}" + return {"enabled": True, "has_password": bool(p.portal_password_hash), "link_url": link_url} + + +@app.post("/projects/{project_id}/portal-access/password") +async def project_portal_access_password(project_id: str, db: Session = Depends(get_db)): + """Generate a fresh strong password, store its hash, return the raw once.""" + from backend.models import Project + from backend.auth_passwords import hash_password, generate_password + p = db.query(Project).filter_by(id=project_id).first() + if not p: + return JSONResponse(status_code=404, content={"detail": "Project not found"}) + raw = generate_password() + p.portal_password_hash = hash_password(raw) + db.commit() + return {"password": raw} + + +@app.post("/projects/{project_id}/portal-access/disable") +async def project_portal_access_disable(project_id: str, db: Session = Depends(get_db)): + """Turn the portal off and rotate the link token (kills the old link).""" + import secrets + from backend.models import Project + p = db.query(Project).filter_by(id=project_id).first() + if not p: + return JSONResponse(status_code=404, content={"detail": "Project not found"}) + p.portal_enabled = False + p.portal_link_token = secrets.token_urlsafe(24) # rotate so the old link 404s + db.commit() + return {"enabled": False} + + @app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse) async def nrl_detail_page( request: Request, diff --git a/tests/test_portal_access_admin.py b/tests/test_portal_access_admin.py new file mode 100644 index 0000000..9c47c65 --- /dev/null +++ b/tests/test_portal_access_admin.py @@ -0,0 +1,40 @@ +from tests.conftest import make_project +from backend.models import Project + + +def test_enable_creates_link_token_and_reports_state(client, db_session): + p = make_project(db_session) + r = client.post(f"/projects/{p.id}/portal-access/enable") + assert r.status_code == 200 + body = r.json() + assert body["enabled"] is True + assert body["link_url"].endswith(f"/portal/p/{db_session.get(Project, p.id).portal_link_token}") + + +def test_set_password_returns_raw_once_and_stores_hash(client, db_session): + p = make_project(db_session) + client.post(f"/projects/{p.id}/portal-access/enable") + r = client.post(f"/projects/{p.id}/portal-access/password") + assert r.status_code == 200 + raw = r.json()["password"] + assert len(raw) >= 12 + fresh = db_session.get(Project, p.id) + assert fresh.portal_password_hash and fresh.portal_password_hash != raw + + +def test_disable_turns_off_and_rotates_token(client, db_session): + p = make_project(db_session) + client.post(f"/projects/{p.id}/portal-access/enable") + old = db_session.get(Project, p.id).portal_link_token + r = client.post(f"/projects/{p.id}/portal-access/disable") + assert r.status_code == 200 + fresh = db_session.get(Project, p.id) + assert fresh.portal_enabled is False + assert fresh.portal_link_token != old + + +def test_get_state(client, db_session): + p = make_project(db_session) + r = client.get(f"/projects/{p.id}/portal-access") + assert r.status_code == 200 + assert r.json() == {"enabled": False, "has_password": False, "link_url": None}