mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user