feat(liveview): header View link opens active design as read-only canvas

Add an authenticated GET /api/v1/liveview/config endpoint exposing the
configured LIVEVIEW_KEY to logged-in admins, so the UI builds a
ready-to-use share link. The header View button opens
/view?key=...&design=<activeDesignId>; LiveView forwards ?design=<id>
to the public endpoint, which renders that design's canvas.

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-06-04 14:10:29 +02:00
parent 1a426f6301
commit 05fb67e7f0
8 changed files with 161 additions and 7 deletions
+22
View File
@@ -2,9 +2,11 @@ import hmac
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.config import settings
from app.db.database import get_db
from app.db.models import CanvasState, Design, Edge, Node
@@ -15,6 +17,26 @@ from app.schemas.nodes import NodeResponse
router = APIRouter()
class LiveViewConfigResponse(BaseModel):
"""Whether live view is enabled, plus the key (admin-only) to build share links."""
enabled: bool
key: str | None = None
@router.get("/config", response_model=LiveViewConfigResponse)
async def liveview_config(
_: str = Depends(get_current_user),
) -> LiveViewConfigResponse:
"""Authenticated: expose the configured live view key so the UI can build a
ready-to-use share link (e.g. /view?key=...&design=<id>).
Only reachable by a logged-in user — the key is never exposed publicly.
"""
key = settings.liveview_key or None
return LiveViewConfigResponse(enabled=bool(key), key=key)
@router.get("", response_model=CanvasStateResponse)
async def liveview_canvas(
key: str | None = Query(default=None),
+81
View File
@@ -146,3 +146,84 @@ async def test_liveview_disabled_after_key_cleared(client: AsyncClient):
res = await client.get("/api/v1/liveview?key=was-enabled")
assert res.status_code == 403
assert res.json()["detail"] == "Live view is disabled"
# ── /config (authenticated) — key used to build share links ──────────────────
@pytest.mark.asyncio
async def test_liveview_config_requires_auth(client: AsyncClient):
"""The config endpoint exposes the key, so it must reject unauthenticated calls."""
settings.liveview_key = "secret"
res = await client.get("/api/v1/liveview/config")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_liveview_config_returns_key_when_enabled(client: AsyncClient, auth_headers):
settings.liveview_key = "share-me"
headers = await auth_headers()
res = await client.get("/api/v1/liveview/config", headers=headers)
assert res.status_code == 200
body = res.json()
assert body == {"enabled": True, "key": "share-me"}
@pytest.mark.asyncio
async def test_liveview_config_disabled_hides_key(client: AsyncClient, auth_headers):
settings.liveview_key = None
headers = await auth_headers()
res = await client.get("/api/v1/liveview/config", headers=headers)
assert res.status_code == 200
assert res.json() == {"enabled": False, "key": None}
@pytest.mark.asyncio
async def test_liveview_config_empty_key_disabled(client: AsyncClient, auth_headers):
settings.liveview_key = ""
headers = await auth_headers()
res = await client.get("/api/v1/liveview/config", headers=headers)
assert res.status_code == 200
assert res.json() == {"enabled": False, "key": None}
# ── design_id selects which canvas is rendered ───────────────────────────────
@pytest.mark.asyncio
async def test_liveview_design_id_selects_canvas(client: AsyncClient, auth_headers):
"""?design_id=<id> renders that design's canvas, not the first one."""
settings.liveview_key = "test-key"
headers = await auth_headers()
# Create two designs
d1 = (await client.post("/api/v1/designs", json={"name": "Network"}, headers=headers)).json()
d2 = (await client.post("/api/v1/designs", json={"name": "Electrical"}, headers=headers)).json()
# Save a distinct node into each design
for design, node_id, label in ((d1, "n-net", "Net Node"), (d2, "n-elec", "Elec Node")):
payload = {
"nodes": [{
"id": node_id,
"type": "server",
"label": label,
"status": "online",
"services": [],
"pos_x": 0,
"pos_y": 0,
}],
"edges": [],
"viewport": {"x": 0, "y": 0, "zoom": 1},
"design_id": design["id"],
}
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
# Requesting d2 returns only the electrical node
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d2['id']}")
assert res.status_code == 200
nodes = res.json()["nodes"]
assert [n["id"] for n in nodes] == ["n-elec"]
# Requesting d1 returns only the network node
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d1['id']}")
assert res.status_code == 200
nodes = res.json()["nodes"]
assert [n["id"] for n in nodes] == ["n-net"]