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
+25 -7
View File
@@ -106,6 +106,15 @@ export default function Editor({ isPublicView }: EditorProps) {
// Derived
const { boardData, loading, error } = useBoardLoader(boardId);
const resolvedBoardId = boardId || boardData?.board?.id;
const userRole = boardData?.role || 'viewer';
const readOnly = !isPublicView && userRole === 'viewer';
// Set read-only status when board data loads
useEffect(() => {
if (boardData && readOnly) {
setSaveStatus('readonly');
}
}, [boardData, readOnly]);
// Toast helper
const showToast = useCallback((text: string) => {
@@ -115,7 +124,7 @@ export default function Editor({ isPublicView }: EditorProps) {
}, []);
// Save manager
const { scheduleSave } = useSaveManager({ resolvedBoardId, isPublicView, canvasRef, setSaveStatus });
const { scheduleSave } = useSaveManager({ resolvedBoardId, isPublicView, readOnly, canvasRef, setSaveStatus });
// Canvas change handler.
// Pass changedIds for incremental sync (fast, lightweight).
@@ -303,18 +312,26 @@ export default function Editor({ isPublicView }: EditorProps) {
});
}, [writeCanvasToClipboard, onCanvasChange, handleGroup, handleUngroup, refreshLayers]);
// Save on page unload
// Save on page unload (only for users with edit access)
useEffect(() => {
if (readOnly || isPublicView) return;
function onBeforeUnload() {
const scene = canvasRef.current?.getScene();
if (!scene || !resolvedBoardId) return;
const token = localStorage.getItem('refboard_token');
if (!token) return;
const state = JSON.stringify(scene.serialize());
const blob = new Blob([JSON.stringify({ canvas_state: state })], { type: 'application/json' });
navigator.sendBeacon(`/api/boards/${resolvedBoardId}/save`, blob);
// Use fetch with keepalive to include auth header (sendBeacon can't set headers)
fetch(`/api/boards/${resolvedBoardId}/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ canvas_state: state }),
keepalive: true,
}).catch(() => {});
}
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [resolvedBoardId]);
}, [resolvedBoardId, readOnly, isPublicView]);
// Update UI overlays (selection toolbar, video controls, minimap) on demand
const updateOverlays = useCallback(() => {
@@ -782,12 +799,13 @@ export default function Editor({ isPublicView }: EditorProps) {
}
function StatusIndicator({ status }: { status: SaveStatus }) {
const cfg = {
const cfg: Record<SaveStatus, { color: string; label: string }> = {
saved: { color: '#4ade80', label: 'Saved' },
saving: { color: '#facc15', label: 'Saving...' },
unsaved: { color: '#f87171', label: 'Unsaved' },
readonly: { color: '#4dabf7', label: 'Read-only' },
};
const s = cfg[status];
const s = cfg[status] || cfg.saved;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<div style={{ width: '5px', height: '5px', borderRadius: '50%', background: s.color }} />