fix: hardening pass — permissions, socket reconnect, canvas setup, arrangements

- Fix 403 on save for public collection viewers (return role in GET board response)
- Add read-only status indicator (StatusBar + StatusIndicator)
- Fix beforeunload save to use fetch+keepalive with auth header
- Socket reconnect now rejoins board room automatically
- Canvas setup uses polling instead of brittle 200ms timer
- Fix double user:left on disconnect (use disconnecting event, snapshot rooms)
- Thread + comment creation wrapped in db.transaction
- Prevent owner downgrade via addCollectionMember (check existing member)
- Bound redirect depth in downloadImage to 5
- Arrangement operations anchor to bounding box top-left (no drift)
- Distribute H/V also anchor to top-left
- Fix annotations fetch to use axios api instance (401 interceptor)
- Replace require() with static import in shortcut-definitions
This commit is contained in:
Hiren Kangad
2026-03-11 08:08:21 +05:30
parent fc2d9df741
commit 6518ed6763
13 changed files with 141 additions and 84 deletions
+19 -14
View File
@@ -13,6 +13,7 @@ import { AnnotationStore } from '../stores/annotationStore';
import { PinOverlay } from '../canvas/PinOverlay';
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
import { connectSocket, disconnectSocket } from '../socket';
import api from '../api';
interface OnlineUser {
userId: string;
@@ -73,10 +74,20 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
useEffect(() => {
if (!boardData || !resolvedBoardId) return;
const timer = setTimeout(() => {
let attempts = 0;
const maxAttempts = 100; // 5 seconds max (100 × 50ms)
const poll = setInterval(() => {
attempts++;
const scene = canvasRef.current?.getScene();
const viewport = canvasRef.current?.getViewport();
if (!scene || !viewport) return;
if (!scene || !viewport) {
if (attempts >= maxAttempts) {
clearInterval(poll);
console.error('[canvas-setup] Canvas not ready after 5s, giving up');
}
return;
}
clearInterval(poll);
// Create SelectionManager
const selection = new SelectionManager(viewport, scene);
@@ -309,17 +320,11 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
});
// ── Annotations: load threads, wire socket events ──
const token = localStorage.getItem('refboard_token');
if (token) {
fetch(`/api/boards/${resolvedBoardId}/threads`, {
headers: { Authorization: `Bearer ${token}` },
api.get(`/api/boards/${resolvedBoardId}/threads`)
.then((res) => {
if (res.data.threads) annotationStoreRef.current?.loadThreads(res.data.threads);
})
.then((r) => r.json())
.then((data) => {
if (data.threads) annotationStoreRef.current?.loadThreads(data.threads);
})
.catch((err) => console.error('[annotations] load threads error:', err));
}
.catch((err) => console.error('[annotations] load threads error:', err));
socket.on('thread:add', (data: any) => {
annotationStoreRef.current?.onThreadAdd(data.thread, data.comment);
@@ -372,10 +377,10 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
}
pasteCleanupRef.current = setupPaste(viewport, scene, resolvedBoardId, onCanvasChange, selection, uploadManager);
}
}, 200);
}, 50);
return () => {
clearTimeout(timer);
clearInterval(poll);
selectionRef.current?.destroy();
selectionRef.current = null;
if (inboxZoneRef.current) {
+10 -5
View File
@@ -6,6 +6,7 @@ import type { SaveStatus } from '../components/StatusBar';
interface SaveManagerOptions {
resolvedBoardId: string | undefined;
isPublicView?: boolean;
readOnly?: boolean;
canvasRef: React.RefObject<PixiCanvasHandle | null>;
setSaveStatus: (s: SaveStatus) => void;
}
@@ -13,11 +14,11 @@ interface SaveManagerOptions {
/**
* Debounced save with thumbnail generation from PixiJS renderer.
*/
export function useSaveManager({ resolvedBoardId, isPublicView, canvasRef, setSaveStatus }: SaveManagerOptions) {
export function useSaveManager({ resolvedBoardId, isPublicView, readOnly, canvasRef, setSaveStatus }: SaveManagerOptions) {
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scheduleSave = useCallback(() => {
if (!resolvedBoardId || isPublicView) return;
if (!resolvedBoardId || isPublicView || readOnly) return;
setSaveStatus('unsaved');
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
@@ -59,11 +60,15 @@ export function useSaveManager({ resolvedBoardId, isPublicView, canvasRef, setSa
}
await saveCanvas(resolvedBoardId, state, thumbnail);
setSaveStatus('saved');
} catch {
setSaveStatus('unsaved');
} catch (err: any) {
if (err?.response?.status === 403) {
setSaveStatus('readonly');
} else {
setSaveStatus('unsaved');
}
}
}, 2000);
}, [resolvedBoardId, isPublicView, canvasRef, setSaveStatus]);
}, [resolvedBoardId, isPublicView, readOnly, canvasRef, setSaveStatus]);
return { scheduleSave, saveTimerRef };
}