feat: operator portal-access endpoints (enable/password/disable/state)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 23:55:10 +00:00
parent b8e4718318
commit 25a4a28433
2 changed files with 97 additions and 0 deletions
+57
View File
@@ -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,
+40
View File
@@ -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}