refactor: retire interim magic-link/open-link in favor of password gate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 00:06:02 +00:00
parent 0394f4b0c8
commit f0a13ea2ff
4 changed files with 24 additions and 117 deletions
+4 -63
View File
@@ -69,7 +69,7 @@ from backend.templates_config import templates
# Client-portal auth: an unauthenticated portal request renders the access page
# (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every
# portal route can simply Depends(get_current_client).
from backend.portal_auth import PortalAuthError, PORTAL_OPEN_LINKS
from backend.portal_auth import PortalAuthError
@app.exception_handler(PortalAuthError)
async def portal_auth_handler(request: Request, exc: PortalAuthError):
@@ -410,83 +410,24 @@ async def project_detail_page(request: Request, project_id: str):
return templates.TemplateResponse("projects/detail.html", {
"request": request,
"project_id": project_id,
"portal_open_links": PORTAL_OPEN_LINKS,
})
@app.get("/projects/{project_id}/portal-preview")
async def project_portal_preview(project_id: str, db: Session = Depends(get_db)):
"""Operator testing shortcut: log into the client portal scoped to this project
(auto-provisioning a client/link if needed), no CLI. Lives under /projects (not
/portal), so a public proxy that exposes only /portal/* won't expose this."""
"""Operator testing shortcut: open this project's client portal (no CLI)."""
from backend.models import Project
from backend.portal_auth import (
provision_preview_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE,
)
from backend.portal_auth import mint_portal_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE
project = db.query(Project).filter_by(id=project_id).first()
if not project:
return JSONResponse(status_code=404, content={"detail": "Project not found"})
token_id = provision_preview_session(project, db)
token_id = mint_portal_session(project, db)
resp = RedirectResponse(url="/portal", status_code=303)
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
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/<token> 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}/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."""