fix(camera): restore new-window camera view with auth enabled

Two root causes in the "Camera View Mode = Window" path when auth is on (#979):

  1. PrintersPage opened the popup with `noopener`, which severed the opener
     link and prevented the browser from copying sessionStorage (auth token)
     into the new window. The popup booted unauthenticated, POST
     /printers/camera/stream-token returned 401, and the <img> src went out
     with no ?token=. The backend's RequireCameraStreamTokenIfAuthEnabled
     then rejected every frame with "Valid camera stream token required".
  2. CameraPage computed its stream URL from the module-level stream-token
     cache in withStreamToken(). That cache is populated by a useEffect in
     useStreamTokenSync that runs after render, so even after the token
     resolved the first post-arrival render still produced a tokenless URL
     and nothing triggered another render.

  Fix:
  - Drop `noopener` from the camera popup features (same-origin, trusted).
  - Subscribe CameraPage to the `camera-stream-token` React Query so the
    page re-renders the moment the token arrives.
  - Gate currentUrl on `waitingForStreamToken` and append the token directly
    from the reactive query value instead of the effect-synced module cache.

  Embedded overlay mode was unaffected. Added CameraPage tests covering both
  the auth-enabled (token required, src empty until it arrives, then includes
  ?token=) and auth-disabled (src rendered immediately without token) paths.
This commit is contained in:
maziggy
2026-04-15 08:06:27 +02:00
parent 1b43488016
commit 62950e37c8
6 changed files with 499 additions and 424 deletions
@@ -127,6 +127,56 @@ describe('CameraPage', () => {
});
});
describe('stream token handling (#979)', () => {
it('does not render image src until stream token arrives when auth is enabled', async () => {
let resolveToken!: (value: unknown) => void;
const tokenPromise = new Promise((resolve) => {
resolveToken = resolve;
});
server.use(
http.get('*/api/v1/auth/status', () =>
HttpResponse.json({ auth_enabled: true, requires_setup: false })
),
http.post('*/api/v1/printers/camera/stream-token', async () => {
await tokenPromise;
return HttpResponse.json({ token: 'tok-abc' });
})
);
renderCameraPage(1);
// Before the token resolves the <img> should not have a src pointing at
// the stream endpoint — otherwise the backend would 401 with the
// "Valid camera stream token required" error from #979.
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
const img = document.querySelector('img') as HTMLImageElement | null;
expect(img).not.toBeNull();
expect(img?.getAttribute('src') || '').not.toContain('/camera/stream');
resolveToken(undefined);
// After the token resolves the image src picks it up as ?token=...
await waitFor(() => {
const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
expect(src).toContain('/camera/stream');
expect(src).toContain('token=tok-abc');
});
});
it('renders image src immediately when auth is disabled (no token required)', async () => {
renderCameraPage(1);
await waitFor(() => {
const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
expect(src).toContain(`/api/v1/printers/1/camera/stream`);
expect(src).not.toContain('token=');
});
});
});
describe('invalid printer', () => {
it('shows invalid printer message for ID 0', async () => {
renderCameraPage(0);
+27 -5
View File
@@ -3,9 +3,10 @@ import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
import { api, getAuthToken, withStreamToken } from '../api/client';
import { api, getAuthToken, getStreamToken, withStreamToken } from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { useStreamTokenSync } from '../hooks/useCameraStreamToken';
import { ChamberLight } from '../components/icons/ChamberLight';
import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
@@ -18,10 +19,22 @@ export function CameraPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const { hasPermission, authEnabled } = useAuth();
const { printerId } = useParams<{ printerId: string }>();
const id = parseInt(printerId || '0', 10);
// Subscribe to the stream-token query so this page re-renders once the token
// arrives. useStreamTokenSync (mounted in App) already owns the fetch; this
// useQuery call dedupes via the shared key and just reads the cached value.
useStreamTokenSync();
const { data: streamTokenData } = useQuery({
queryKey: ['camera-stream-token'],
queryFn: () => api.getCameraStreamToken(),
enabled: authEnabled,
staleTime: 50 * 60 * 1000,
});
const streamTokenValue = streamTokenData?.token ?? getStreamToken();
const [streamMode, setStreamMode] = useState<'stream' | 'snapshot'>('stream');
const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
const [streamError, setStreamError] = useState(false);
@@ -574,11 +587,20 @@ export function CameraPage() {
setPanOffset({ x: 0, y: 0 });
};
const currentUrl = transitioning
// When auth is enabled, wait for the stream token before rendering the <img>
// src — otherwise the first request fires without ?token= and the backend
// rejects it with "Valid camera stream token required" (see #979). We append
// the token directly from the reactive query value instead of relying on the
// module-level cache in withStreamToken(), because that cache is updated in a
// useEffect that runs after render.
const waitingForStreamToken = authEnabled && !streamTokenValue;
const appendToken = (url: string) =>
streamTokenValue ? `${url}&token=${encodeURIComponent(streamTokenValue)}` : withStreamToken(url);
const currentUrl = transitioning || waitingForStreamToken
? ''
: streamMode === 'stream'
? withStreamToken(`/api/v1/printers/${id}/camera/stream?fps=15&t=${imageKey}`)
: withStreamToken(`/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`);
? appendToken(`/api/v1/printers/${id}/camera/stream?fps=15&t=${imageKey}`)
: appendToken(`/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`);
const isDisabled = streamLoading || transitioning || isReconnecting;
+3 -1
View File
@@ -4151,7 +4151,9 @@ function PrinterCard({
`height=${state.height}`,
state.left !== undefined ? `left=${state.left}` : '',
state.top !== undefined ? `top=${state.top}` : '',
'menubar=no,toolbar=no,location=no,status=no,noopener',
// No `noopener`: same-origin popup needs opener so the browser
// copies sessionStorage (auth token) into the new window.
'menubar=no,toolbar=no,location=no,status=no',
].filter(Boolean).join(',');
window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
}