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
+9 -1
View File
@@ -552,6 +552,14 @@ function createThread({ id, boardId, objectId, anchorType, pinX, pinY, createdBy
return getThread(id); return getThread(id);
} }
function createThreadWithComment({ threadId, boardId, objectId, anchorType, pinX, pinY, createdBy, commentId, userId, authorName, authorColor, content }) {
return db.transaction(() => {
const thread = createThread({ id: threadId, boardId, objectId, anchorType, pinX, pinY, createdBy });
const comment = createComment({ id: commentId, threadId, userId, authorName, authorColor, content });
return { thread, comment };
})();
}
function updateThreadStatus(threadId, status, resolvedBy) { function updateThreadStatus(threadId, status, resolvedBy) {
if (status === 'resolved') { if (status === 'resolved') {
db.prepare(` db.prepare(`
@@ -650,7 +658,7 @@ module.exports = {
// Media Jobs // Media Jobs
createMediaJob, updateMediaJob, getMediaJob, getPendingMediaJobs, updateImageMedia, createMediaJob, updateMediaJob, getMediaJob, getPendingMediaJobs, updateImageMedia,
// Threads // Threads
getThreadsByBoard, getThread, createThread, updateThreadStatus, deleteThread, getThreadsByBoard, getThread, createThread, createThreadWithComment, updateThreadStatus, deleteThread,
incrementThreadCommentCount, decrementThreadCommentCount, incrementThreadCommentCount, decrementThreadCommentCount,
// Comments // Comments
getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment, getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment,
+1
View File
@@ -153,6 +153,7 @@ router.get('/:boardId', (req, res) => {
name: collection.name, name: collection.name,
}, },
images, images,
role: result.member?.role || 'viewer',
}); });
} catch (err) { } catch (err) {
console.error('[boards] get error:', err); console.error('[boards] get error:', err);
+5
View File
@@ -263,6 +263,11 @@ router.post('/:collectionId/members', (req, res) => {
return res.status(400).json({ error: 'Cannot add yourself' }); return res.status(400).json({ error: 'Cannot add yourself' });
} }
const existingMember = getCollectionMember(collection.id, targetUser.id);
if (existingMember) {
return res.status(400).json({ error: 'User is already a member' });
}
addCollectionMember(collection.id, targetUser.id, memberRole); addCollectionMember(collection.id, targetUser.id, memberRole);
return res.status(201).json({ return res.status(201).json({
+4 -7
View File
@@ -5,6 +5,7 @@ const {
getThreadsByBoard, getThreadsByBoard,
getThread, getThread,
createThread, createThread,
createThreadWithComment,
updateThreadStatus, updateThreadStatus,
deleteThread, deleteThread,
getCommentsByBoard, getCommentsByBoard,
@@ -82,19 +83,15 @@ router.post('/:boardId/threads', (req, res) => {
const commentId = uuidv4(); const commentId = uuidv4();
const userId = req.user.id; const userId = req.user.id;
const thread = createThread({ const { thread, comment } = createThreadWithComment({
id: threadId, threadId,
boardId: req.params.boardId, boardId: req.params.boardId,
objectId: object_id, objectId: object_id,
anchorType: anchor_type || 'object', anchorType: anchor_type || 'object',
pinX: pin_x, pinX: pin_x,
pinY: pin_y, pinY: pin_y,
createdBy: userId, createdBy: userId,
}); commentId,
const comment = createComment({
id: commentId,
threadId,
userId, userId,
authorName: resolveAuthorName(req.user), authorName: resolveAuthorName(req.user),
authorColor: null, authorColor: null,
+6 -3
View File
@@ -178,15 +178,18 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
/** /**
* Download an image from a URL. Returns { buffer, mimeType, filename }. * Download an image from a URL. Returns { buffer, mimeType, filename }.
*/ */
function downloadImage(imageUrl) { function downloadImage(imageUrl, maxRedirects = 5) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const parsed = new URL(imageUrl); const parsed = new URL(imageUrl);
const client = parsed.protocol === 'https:' ? https : http; const client = parsed.protocol === 'https:' ? https : http;
client.get(imageUrl, { timeout: 30000 }, (response) => { client.get(imageUrl, { timeout: 30000 }, (response) => {
// Follow redirects (up to 5) // Follow redirects up to maxRedirects times
if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) { if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
return downloadImage(response.headers.location).then(resolve).catch(reject); if (maxRedirects <= 0) {
return reject(new Error('Too many redirects'));
}
return downloadImage(response.headers.location, maxRedirects - 1).then(resolve).catch(reject);
} }
if (response.statusCode !== 200) { if (response.statusCode !== 200) {
+3 -6
View File
@@ -150,16 +150,13 @@ function setupBoardRoom(io, socket) {
// ---- Disconnect cleanup ---- // ---- Disconnect cleanup ----
socket.on('disconnect', () => { socket.on('disconnecting', () => {
for (const room of socket.rooms) { const rooms = [...socket.rooms];
for (const room of rooms) {
if (room.startsWith('board:')) { if (room.startsWith('board:')) {
leaveRoom(io, socket, room); leaveRoom(io, socket, room);
} }
} }
if (socket.currentBoardId) {
const roomName = getRoomName(socket.currentBoardId);
leaveRoom(io, socket, roomName);
}
}); });
} }
+37 -32
View File
@@ -135,15 +135,15 @@ export function alignBottom(objects: SceneItem[]) {
/** Distribute horizontally: normalize all to same height, then space evenly in a row. */ /** Distribute horizontally: normalize all to same height, then space evenly in a row. */
export function distributeHorizontal(objects: SceneItem[]) { export function distributeHorizontal(objects: SceneItem[]) {
if (objects.length < 2) return; if (objects.length < 2) return;
const { x: startX, y: startY } = anchorTopLeft(objects);
// Normalize heights first (uniform row) // Normalize heights first (uniform row)
normalizeHeight(objects); normalizeHeight(objects);
// Then arrange as row with even spacing // Then arrange as row with even spacing
const gap = 20; const gap = 20;
const sorted = [...objects].sort((a, b) => a.data.x - b.data.x); const sorted = [...objects].sort((a, b) => a.data.x - b.data.x);
const startY = sorted[0].data.y; let x = 0;
let x = sorted[0].data.x;
sorted.forEach((item) => { sorted.forEach((item) => {
item.data.x = x; item.data.x = startX + x;
item.data.y = startY; item.data.y = startY;
syncPosition(item); syncPosition(item);
x += scaledW(item) + gap; x += scaledW(item) + gap;
@@ -153,16 +153,16 @@ export function distributeHorizontal(objects: SceneItem[]) {
/** Distribute vertically: normalize all to same width, then space evenly in a column. */ /** Distribute vertically: normalize all to same width, then space evenly in a column. */
export function distributeVertical(objects: SceneItem[]) { export function distributeVertical(objects: SceneItem[]) {
if (objects.length < 2) return; if (objects.length < 2) return;
const { x: startX, y: startY } = anchorTopLeft(objects);
// Normalize widths first (uniform column) // Normalize widths first (uniform column)
normalizeWidth(objects); normalizeWidth(objects);
// Then arrange as column with even spacing // Then arrange as column with even spacing
const gap = 20; const gap = 20;
const sorted = [...objects].sort((a, b) => a.data.y - b.data.y); const sorted = [...objects].sort((a, b) => a.data.y - b.data.y);
const startX = sorted[0].data.x; let y = 0;
let y = sorted[0].data.y;
sorted.forEach((item) => { sorted.forEach((item) => {
item.data.x = startX; item.data.x = startX;
item.data.y = y; item.data.y = startY + y;
syncPosition(item); syncPosition(item);
y += scaledH(item) + gap; y += scaledH(item) + gap;
}); });
@@ -223,15 +223,22 @@ export function normalizeWidth(objects: SceneItem[]) {
// ─── Arrangement ─── // ─── Arrangement ───
/** Get the top-left corner of the bounding box of all items. */
function anchorTopLeft(objects: SceneItem[]): { x: number; y: number } {
return {
x: Math.min(...objects.map((item) => item.data.x)),
y: Math.min(...objects.map((item) => item.data.y)),
};
}
export function arrangeOptimal(objects: SceneItem[]) { export function arrangeOptimal(objects: SceneItem[]) {
if (objects.length < 2) return; if (objects.length < 2) return;
const { x: startX, y: startY } = anchorTopLeft(objects);
// Shelf-based bin packing, sorted by height descending // Shelf-based bin packing, sorted by height descending
const sorted = [...objects].sort((a, b) => scaledH(b) - scaledH(a)); const sorted = [...objects].sort((a, b) => scaledH(b) - scaledH(a));
const gap = 10; const gap = 10;
const totalArea = sorted.reduce((s, item) => s + scaledW(item) * scaledH(item), 0); const totalArea = sorted.reduce((s, item) => s + scaledW(item) * scaledH(item), 0);
const shelfWidth = Math.sqrt(totalArea) * 1.3; const shelfWidth = Math.sqrt(totalArea) * 1.3;
const startX = sorted[0].data.x;
const startY = sorted[0].data.y;
let x = 0, y = 0, shelfHeight = 0; let x = 0, y = 0, shelfHeight = 0;
sorted.forEach((item) => { sorted.forEach((item) => {
const w = scaledW(item); const w = scaledW(item);
@@ -251,10 +258,9 @@ export function arrangeOptimal(objects: SceneItem[]) {
export function arrangeGrid(objects: SceneItem[]) { export function arrangeGrid(objects: SceneItem[]) {
if (objects.length < 2) return; if (objects.length < 2) return;
const { x: startX, y: startY } = anchorTopLeft(objects);
const gap = 20; const gap = 20;
const cols = Math.ceil(Math.sqrt(objects.length)); const cols = Math.ceil(Math.sqrt(objects.length));
const startX = objects[0].data.x;
const startY = objects[0].data.y;
const maxW = Math.max(...objects.map(scaledW)); const maxW = Math.max(...objects.map(scaledW));
const maxH = Math.max(...objects.map(scaledH)); const maxH = Math.max(...objects.map(scaledH));
objects.forEach((item, i) => { objects.forEach((item, i) => {
@@ -268,12 +274,12 @@ export function arrangeGrid(objects: SceneItem[]) {
export function arrangeRow(objects: SceneItem[]) { export function arrangeRow(objects: SceneItem[]) {
if (objects.length < 2) return; if (objects.length < 2) return;
const gap = 20; const { x: startX, y: startY } = anchorTopLeft(objects);
const sorted = [...objects].sort((a, b) => a.data.x - b.data.x); const sorted = [...objects].sort((a, b) => a.data.x - b.data.x);
const startY = sorted[0].data.y; const gap = 20;
let x = sorted[0].data.x; let x = 0;
sorted.forEach((item) => { sorted.forEach((item) => {
item.data.x = x; item.data.x = startX + x;
item.data.y = startY; item.data.y = startY;
syncPosition(item); syncPosition(item);
x += scaledW(item) + gap; x += scaledW(item) + gap;
@@ -282,13 +288,13 @@ export function arrangeRow(objects: SceneItem[]) {
export function arrangeColumn(objects: SceneItem[]) { export function arrangeColumn(objects: SceneItem[]) {
if (objects.length < 2) return; if (objects.length < 2) return;
const gap = 20; const { x: startX, y: startY } = anchorTopLeft(objects);
const sorted = [...objects].sort((a, b) => a.data.y - b.data.y); const sorted = [...objects].sort((a, b) => a.data.y - b.data.y);
const startX = sorted[0].data.x; const gap = 20;
let y = sorted[0].data.y; let y = 0;
sorted.forEach((item) => { sorted.forEach((item) => {
item.data.x = startX; item.data.x = startX;
item.data.y = y; item.data.y = startY + y;
syncPosition(item); syncPosition(item);
y += scaledH(item) + gap; y += scaledH(item) + gap;
}); });
@@ -309,41 +315,40 @@ export function arrangeByName(objects: SceneItem[]) {
const sorted = [...objects].sort((a, b) => const sorted = [...objects].sort((a, b) =>
(a.data.name || '').localeCompare(b.data.name || '') (a.data.name || '').localeCompare(b.data.name || '')
); );
layoutAsGrid(sorted); layoutAsGrid(sorted, anchorTopLeft(objects));
} }
export function arrangeByZOrder(objects: SceneItem[]) { export function arrangeByZOrder(objects: SceneItem[]) {
// Sort by z-order stored in item.data.z
const sorted = [...objects].sort((a, b) => a.data.z - b.data.z); const sorted = [...objects].sort((a, b) => a.data.z - b.data.z);
layoutAsGrid(sorted); layoutAsGrid(sorted, anchorTopLeft(objects));
} }
export function arrangeRandomly(objects: SceneItem[]) { export function arrangeRandomly(objects: SceneItem[]) {
if (objects.length < 2) return; if (objects.length < 2) return;
const minX = Math.min(...objects.map((item) => item.data.x)); const { x: startX, y: startY } = anchorTopLeft(objects);
const minY = Math.min(...objects.map((item) => item.data.y)); // Compute spread area based on total content size
const maxX = Math.max(...objects.map((item) => item.data.x + scaledW(item))); const totalW = objects.reduce((s, item) => s + scaledW(item), 0);
const maxY = Math.max(...objects.map((item) => item.data.y + scaledH(item))); const totalH = objects.reduce((s, item) => s + scaledH(item), 0);
const spreadW = Math.sqrt(totalW * totalH) * 1.5;
const spreadH = spreadW;
objects.forEach((item) => { objects.forEach((item) => {
item.data.x = minX + Math.random() * (maxX - minX - scaledW(item)); item.data.x = startX + Math.random() * spreadW;
item.data.y = minY + Math.random() * (maxY - minY - scaledH(item)); item.data.y = startY + Math.random() * spreadH;
syncPosition(item); syncPosition(item);
}); });
} }
function layoutAsGrid(sorted: SceneItem[]) { function layoutAsGrid(sorted: SceneItem[], anchor: { x: number; y: number }) {
if (sorted.length < 2) return; if (sorted.length < 2) return;
const gap = 20; const gap = 20;
const cols = Math.ceil(Math.sqrt(sorted.length)); const cols = Math.ceil(Math.sqrt(sorted.length));
const startX = sorted[0].data.x;
const startY = sorted[0].data.y;
const maxW = Math.max(...sorted.map(scaledW)); const maxW = Math.max(...sorted.map(scaledW));
const maxH = Math.max(...sorted.map(scaledH)); const maxH = Math.max(...sorted.map(scaledH));
sorted.forEach((item, i) => { sorted.forEach((item, i) => {
const col = i % cols; const col = i % cols;
const row = Math.floor(i / cols); const row = Math.floor(i / cols);
item.data.x = startX + col * (maxW + gap); item.data.x = anchor.x + col * (maxW + gap);
item.data.y = startY + row * (maxH + gap); item.data.y = anchor.y + row * (maxH + gap);
syncPosition(item); syncPosition(item);
}); });
} }
+1 -1
View File
@@ -17,6 +17,7 @@ import { ShortcutDef, ShortcutContext } from './shortcuts';
import type { SceneItem, SceneManager } from './SceneManager'; import type { SceneItem, SceneManager } from './SceneManager';
import type { GroupObject } from './scene-format'; import type { GroupObject } from './scene-format';
import * as ops from './operations'; import * as ops from './operations';
import { onArrangeAnimationDone } from './operations';
// Tracks when the last internal copy happened so paste can decide // Tracks when the last internal copy happened so paste can decide
// whether to use internal clipboard (just copied) vs system clipboard (external app). // whether to use internal clipboard (just copied) vs system clipboard (external app).
@@ -121,7 +122,6 @@ function _opUpdate(ctx: ShortcutContext, op: (items: SceneItem[]) => void): void
/** Like _opUpdate but defers transformBox update until animation completes */ /** Like _opUpdate but defers transformBox update until animation completes */
function _opUpdateAnimated(ctx: ShortcutContext, op: (items: SceneItem[]) => void): void { function _opUpdateAnimated(ctx: ShortcutContext, op: (items: SceneItem[]) => void): void {
const items = ctx.selection.getSelectedItems(); const items = ctx.selection.getSelectedItems();
const { onArrangeAnimationDone } = require('./operations');
onArrangeAnimationDone(() => ctx.selection.transformBox.update(items)); onArrangeAnimationDone(() => ctx.selection.transformBox.update(items));
op(items); op(items);
ctx.onChange(items.map(i => i.id)); ctx.onChange(items.map(i => i.id));
+18 -6
View File
@@ -259,13 +259,24 @@ export function setupSync(
socket.on('element:remove', onElementRemove); socket.on('element:remove', onElementRemove);
socket.on('object:transform', onTransformReceived); socket.on('object:transform', onTransformReceived);
// ---- Join room ------------------------------------------------------------ // ---- Join room (and rejoin on reconnect) ----------------------------------
socket.emit('board:join', { boardId }, (response: any) => { function joinRoom() {
if (response?.users) { socket.emit('board:join', { boardId }, (response: any) => {
socket.emit('room:users', { users: response.users }); if (response?.users) {
} socket.emit('room:users', { users: response.users });
}); }
});
}
// 'connect' fires on both the initial connection and every reconnect,
// ensuring the server always has this client in the board room.
socket.on('connect', joinRoom);
// Emit immediately if already connected (socket was connected before setupSync ran).
if (socket.connected) {
joinRoom();
}
// ---- Return handle -------------------------------------------------------- // ---- Return handle --------------------------------------------------------
@@ -278,6 +289,7 @@ export function setupSync(
}, },
cleanup: () => { cleanup: () => {
sceneManager.onChange = prevOnChange; sceneManager.onChange = prevOnChange;
socket.off('connect', joinRoom);
socket.off('scene:update', onSceneReceived); socket.off('scene:update', onSceneReceived);
socket.off('element:update', onElementUpdate); socket.off('element:update', onElementUpdate);
socket.off('element:remove', onElementRemove); socket.off('element:remove', onElementRemove);
+3 -2
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
export type SaveStatus = 'saved' | 'saving' | 'unsaved'; export type SaveStatus = 'saved' | 'saving' | 'unsaved' | 'readonly';
interface StatusBarProps { interface StatusBarProps {
boardName: string; boardName: string;
@@ -47,10 +47,11 @@ const statusConfig: Record<SaveStatus, { label: string; color: string }> = {
saved: { label: 'Saved', color: '#69db7c' }, saved: { label: 'Saved', color: '#69db7c' },
saving: { label: 'Saving...', color: '#ffd43b' }, saving: { label: 'Saving...', color: '#ffd43b' },
unsaved: { label: 'Unsaved changes', color: '#ff6b6b' }, unsaved: { label: 'Unsaved changes', color: '#ff6b6b' },
readonly: { label: 'Read-only', color: '#4dabf7' },
}; };
export default function StatusBar({ boardName, imageCount, saveStatus }: StatusBarProps) { export default function StatusBar({ boardName, imageCount, saveStatus }: StatusBarProps) {
const status = statusConfig[saveStatus]; const status = statusConfig[saveStatus] || statusConfig.saved;
return ( return (
<div style={styles.bar}> <div style={styles.bar}>
+19 -14
View File
@@ -13,6 +13,7 @@ import { AnnotationStore } from '../stores/annotationStore';
import { PinOverlay } from '../canvas/PinOverlay'; import { PinOverlay } from '../canvas/PinOverlay';
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit // PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
import { connectSocket, disconnectSocket } from '../socket'; import { connectSocket, disconnectSocket } from '../socket';
import api from '../api';
interface OnlineUser { interface OnlineUser {
userId: string; userId: string;
@@ -73,10 +74,20 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
useEffect(() => { useEffect(() => {
if (!boardData || !resolvedBoardId) return; 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 scene = canvasRef.current?.getScene();
const viewport = canvasRef.current?.getViewport(); 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 // Create SelectionManager
const selection = new SelectionManager(viewport, scene); const selection = new SelectionManager(viewport, scene);
@@ -309,17 +320,11 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
}); });
// ── Annotations: load threads, wire socket events ── // ── Annotations: load threads, wire socket events ──
const token = localStorage.getItem('refboard_token'); api.get(`/api/boards/${resolvedBoardId}/threads`)
if (token) { .then((res) => {
fetch(`/api/boards/${resolvedBoardId}/threads`, { if (res.data.threads) annotationStoreRef.current?.loadThreads(res.data.threads);
headers: { Authorization: `Bearer ${token}` },
}) })
.then((r) => r.json()) .catch((err) => console.error('[annotations] load threads error:', err));
.then((data) => {
if (data.threads) annotationStoreRef.current?.loadThreads(data.threads);
})
.catch((err) => console.error('[annotations] load threads error:', err));
}
socket.on('thread:add', (data: any) => { socket.on('thread:add', (data: any) => {
annotationStoreRef.current?.onThreadAdd(data.thread, data.comment); 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); pasteCleanupRef.current = setupPaste(viewport, scene, resolvedBoardId, onCanvasChange, selection, uploadManager);
} }
}, 200); }, 50);
return () => { return () => {
clearTimeout(timer); clearInterval(poll);
selectionRef.current?.destroy(); selectionRef.current?.destroy();
selectionRef.current = null; selectionRef.current = null;
if (inboxZoneRef.current) { if (inboxZoneRef.current) {
+10 -5
View File
@@ -6,6 +6,7 @@ import type { SaveStatus } from '../components/StatusBar';
interface SaveManagerOptions { interface SaveManagerOptions {
resolvedBoardId: string | undefined; resolvedBoardId: string | undefined;
isPublicView?: boolean; isPublicView?: boolean;
readOnly?: boolean;
canvasRef: React.RefObject<PixiCanvasHandle | null>; canvasRef: React.RefObject<PixiCanvasHandle | null>;
setSaveStatus: (s: SaveStatus) => void; setSaveStatus: (s: SaveStatus) => void;
} }
@@ -13,11 +14,11 @@ interface SaveManagerOptions {
/** /**
* Debounced save with thumbnail generation from PixiJS renderer. * 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 saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scheduleSave = useCallback(() => { const scheduleSave = useCallback(() => {
if (!resolvedBoardId || isPublicView) return; if (!resolvedBoardId || isPublicView || readOnly) return;
setSaveStatus('unsaved'); setSaveStatus('unsaved');
if (saveTimerRef.current) clearTimeout(saveTimerRef.current); if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => { saveTimerRef.current = setTimeout(async () => {
@@ -59,11 +60,15 @@ export function useSaveManager({ resolvedBoardId, isPublicView, canvasRef, setSa
} }
await saveCanvas(resolvedBoardId, state, thumbnail); await saveCanvas(resolvedBoardId, state, thumbnail);
setSaveStatus('saved'); setSaveStatus('saved');
} catch { } catch (err: any) {
setSaveStatus('unsaved'); if (err?.response?.status === 403) {
setSaveStatus('readonly');
} else {
setSaveStatus('unsaved');
}
} }
}, 2000); }, 2000);
}, [resolvedBoardId, isPublicView, canvasRef, setSaveStatus]); }, [resolvedBoardId, isPublicView, readOnly, canvasRef, setSaveStatus]);
return { scheduleSave, saveTimerRef }; return { scheduleSave, saveTimerRef };
} }
+25 -7
View File
@@ -106,6 +106,15 @@ export default function Editor({ isPublicView }: EditorProps) {
// Derived // Derived
const { boardData, loading, error } = useBoardLoader(boardId); const { boardData, loading, error } = useBoardLoader(boardId);
const resolvedBoardId = boardId || boardData?.board?.id; 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 // Toast helper
const showToast = useCallback((text: string) => { const showToast = useCallback((text: string) => {
@@ -115,7 +124,7 @@ export default function Editor({ isPublicView }: EditorProps) {
}, []); }, []);
// Save manager // Save manager
const { scheduleSave } = useSaveManager({ resolvedBoardId, isPublicView, canvasRef, setSaveStatus }); const { scheduleSave } = useSaveManager({ resolvedBoardId, isPublicView, readOnly, canvasRef, setSaveStatus });
// Canvas change handler. // Canvas change handler.
// Pass changedIds for incremental sync (fast, lightweight). // Pass changedIds for incremental sync (fast, lightweight).
@@ -303,18 +312,26 @@ export default function Editor({ isPublicView }: EditorProps) {
}); });
}, [writeCanvasToClipboard, onCanvasChange, handleGroup, handleUngroup, refreshLayers]); }, [writeCanvasToClipboard, onCanvasChange, handleGroup, handleUngroup, refreshLayers]);
// Save on page unload // Save on page unload (only for users with edit access)
useEffect(() => { useEffect(() => {
if (readOnly || isPublicView) return;
function onBeforeUnload() { function onBeforeUnload() {
const scene = canvasRef.current?.getScene(); const scene = canvasRef.current?.getScene();
if (!scene || !resolvedBoardId) return; if (!scene || !resolvedBoardId) return;
const token = localStorage.getItem('refboard_token');
if (!token) return;
const state = JSON.stringify(scene.serialize()); const state = JSON.stringify(scene.serialize());
const blob = new Blob([JSON.stringify({ canvas_state: state })], { type: 'application/json' }); // Use fetch with keepalive to include auth header (sendBeacon can't set headers)
navigator.sendBeacon(`/api/boards/${resolvedBoardId}/save`, blob); 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); window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload); return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [resolvedBoardId]); }, [resolvedBoardId, readOnly, isPublicView]);
// Update UI overlays (selection toolbar, video controls, minimap) on demand // Update UI overlays (selection toolbar, video controls, minimap) on demand
const updateOverlays = useCallback(() => { const updateOverlays = useCallback(() => {
@@ -782,12 +799,13 @@ export default function Editor({ isPublicView }: EditorProps) {
} }
function StatusIndicator({ status }: { status: SaveStatus }) { function StatusIndicator({ status }: { status: SaveStatus }) {
const cfg = { const cfg: Record<SaveStatus, { color: string; label: string }> = {
saved: { color: '#4ade80', label: 'Saved' }, saved: { color: '#4ade80', label: 'Saved' },
saving: { color: '#facc15', label: 'Saving...' }, saving: { color: '#facc15', label: 'Saving...' },
unsaved: { color: '#f87171', label: 'Unsaved' }, unsaved: { color: '#f87171', label: 'Unsaved' },
readonly: { color: '#4dabf7', label: 'Read-only' },
}; };
const s = cfg[status]; const s = cfg[status] || cfg.saved;
return ( return (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<div style={{ width: '5px', height: '5px', borderRadius: '50%', background: s.color }} /> <div style={{ width: '5px', height: '5px', borderRadius: '50%', background: s.color }} />