From 75cbe97bc09f89a6873e801a2149f0ed53e62e90 Mon Sep 17 00:00:00 2001
From: tototomate123
Date: Fri, 13 Feb 2026 19:02:03 +0100
Subject: [PATCH] feat(collab): restore cross-account sharing and reliable
realtime sync
---
.../migration.sql | 42 +++
backend/prisma/schema.prisma | 35 ++
backend/src/index.ts | 3 +-
backend/src/routes/dashboard/drawings.ts | 329 ++++++++++++++++--
backend/src/routes/dashboard/types.ts | 2 +
backend/src/server/drawingAccess.ts | 244 +++++++++++++
backend/src/server/socket.ts | 23 +-
frontend/src/App.tsx | 8 +
frontend/src/api/index.ts | 65 +++-
frontend/src/components/DrawingCard.tsx | 135 +++----
frontend/src/components/Sidebar.tsx | 19 +-
frontend/src/pages/Dashboard.tsx | 73 ++--
frontend/src/pages/Editor.tsx | 319 +++++++++++++++--
.../src/pages/dashboard/useDashboardData.ts | 42 ++-
frontend/src/pages/editor/shared.ts | 27 ++
frontend/src/types/index.ts | 10 +
frontend/src/utils/sync.ts | 18 +
frontend/vite.config.ts | 15 +-
18 files changed, 1242 insertions(+), 167 deletions(-)
create mode 100644 backend/prisma/migrations/20260213163825_add_drawing_sharing/migration.sql
create mode 100644 backend/src/server/drawingAccess.ts
diff --git a/backend/prisma/migrations/20260213163825_add_drawing_sharing/migration.sql b/backend/prisma/migrations/20260213163825_add_drawing_sharing/migration.sql
new file mode 100644
index 0000000..23392eb
--- /dev/null
+++ b/backend/prisma/migrations/20260213163825_add_drawing_sharing/migration.sql
@@ -0,0 +1,42 @@
+-- CreateTable
+CREATE TABLE "DrawingShareLink" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "drawingId" TEXT NOT NULL,
+ "role" TEXT NOT NULL,
+ "token" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL,
+ CONSTRAINT "DrawingShareLink_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateTable
+CREATE TABLE "DrawingShareGrant" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "drawingId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "shareLinkId" TEXT NOT NULL,
+ "role" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL,
+ CONSTRAINT "DrawingShareGrant_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "DrawingShareGrant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "DrawingShareGrant_shareLinkId_fkey" FOREIGN KEY ("shareLinkId") REFERENCES "DrawingShareLink" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DrawingShareLink_token_key" ON "DrawingShareLink"("token");
+
+-- CreateIndex
+CREATE INDEX "DrawingShareLink_drawingId_idx" ON "DrawingShareLink"("drawingId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DrawingShareLink_drawingId_role_key" ON "DrawingShareLink"("drawingId", "role");
+
+-- CreateIndex
+CREATE INDEX "DrawingShareGrant_drawingId_userId_idx" ON "DrawingShareGrant"("drawingId", "userId");
+
+-- CreateIndex
+CREATE INDEX "DrawingShareGrant_userId_createdAt_idx" ON "DrawingShareGrant"("userId", "createdAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DrawingShareGrant_drawingId_userId_shareLinkId_key" ON "DrawingShareGrant"("drawingId", "userId", "shareLinkId");
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 2794012..80598fe 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -27,6 +27,7 @@ model User {
passwordResetTokens PasswordResetToken[]
refreshTokens RefreshToken[]
auditLogs AuditLog[]
+ drawingShareGrants DrawingShareGrant[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -67,6 +68,8 @@ model Drawing {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
collectionId String?
collection Collection? @relation(fields: [collectionId], references: [id])
+ shareLinks DrawingShareLink[]
+ shareGrants DrawingShareGrant[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -74,6 +77,38 @@ model Drawing {
@@index([userId, collectionId, updatedAt])
}
+model DrawingShareLink {
+ id String @id @default(uuid())
+ drawingId String
+ drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade)
+ role String
+ token String @unique
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ grants DrawingShareGrant[]
+
+ @@unique([drawingId, role])
+ @@index([drawingId])
+}
+
+model DrawingShareGrant {
+ id String @id @default(uuid())
+ drawingId String
+ drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade)
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ shareLinkId String
+ shareLink DrawingShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade)
+ role String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([drawingId, userId, shareLinkId])
+ @@index([drawingId, userId])
+ @@index([userId, createdAt])
+}
+
model Library {
id String @id // User-specific library ID (e.g., "user_")
items String @default("[]") // Stored as JSON string array of library items
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 795d25b..f600d99 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -282,7 +282,7 @@ app.use(
cors({
origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)),
credentials: true,
- allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token", "x-imported-file"],
+ allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token", "x-imported-file", "x-share-token"],
exposedHeaders: ["x-csrf-token", "x-request-id"],
})
);
@@ -574,6 +574,7 @@ apiApp.get("/health", (req, res) => {
registerDashboardRoutes(apiApp, {
prisma,
+ authModeService,
requireAuth,
asyncHandler,
parseJsonField,
diff --git a/backend/src/routes/dashboard/drawings.ts b/backend/src/routes/dashboard/drawings.ts
index 2673467..a7bbcd3 100644
--- a/backend/src/routes/dashboard/drawings.ts
+++ b/backend/src/routes/dashboard/drawings.ts
@@ -7,6 +7,13 @@ import {
toInternalTrashCollectionId,
toPublicTrashCollectionId,
} from "./trash";
+import {
+ ensureShareLinkForRole,
+ isAtLeastRole,
+ isShareRole,
+ rotateShareLinkForRole,
+ resolveDrawingAccess,
+} from "../../server/drawingAccess";
const getRouteIdParam = (value: string | string[] | undefined): string | null => {
if (typeof value === "string" && value.trim().length > 0) return value;
@@ -16,12 +23,29 @@ const getRouteIdParam = (value: string | string[] | undefined): string | null =>
return null;
};
+const getShareTokenFromRequest = (req: express.Request): string | undefined => {
+ const value = req.headers["x-share-token"];
+ if (typeof value === "string" && value.trim().length > 0) {
+ return value.trim();
+ }
+ if (Array.isArray(value) && typeof value[0] === "string" && value[0].trim().length > 0) {
+ return value[0].trim();
+ }
+ return undefined;
+};
+
+const payloadHasOnlySceneFields = (payload: Record): boolean => {
+ const allowed = new Set(["elements", "appState", "files", "preview", "version"]);
+ return Object.keys(payload).every((key) => allowed.has(key));
+};
+
export const registerDrawingRoutes = (
app: express.Express,
deps: DashboardRouteDeps
) => {
const {
prisma,
+ authModeService,
requireAuth,
asyncHandler,
parseJsonField,
@@ -152,6 +176,7 @@ export const registerDrawingRoutes = (
if (shouldIncludeData) {
responsePayload = (drawings as any[]).map((d: any) => ({
...d,
+ accessRole: "owner",
collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id),
elements: parseJsonField(d.elements, []),
appState: parseJsonField(d.appState, {}),
@@ -160,6 +185,7 @@ export const registerDrawingRoutes = (
} else {
responsePayload = (drawings as any[]).map((d: any) => ({
...d,
+ accessRole: "owner",
collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id),
}));
}
@@ -177,27 +203,246 @@ export const registerDrawingRoutes = (
return res.send(body);
}));
+ app.get("/drawings/shared", requireAuth, asyncHandler(async (req, res) => {
+ if (!req.user) return res.status(401).json({ error: "Unauthorized" });
+
+ const authEnabled = await authModeService.getAuthEnabled();
+ if (!authEnabled) {
+ return res.status(404).json({ error: "Not found" });
+ }
+
+ const { search, includeData, limit, offset, sortField, sortDirection } = req.query;
+ const searchTerm =
+ typeof search === "string" && search.trim().length > 0 ? search.trim() : undefined;
+
+ const shouldIncludeData =
+ typeof includeData === "string"
+ ? includeData.toLowerCase() === "true" || includeData === "1"
+ : false;
+
+ const parsedSortField: SortField =
+ sortField === "name" || sortField === "createdAt" || sortField === "updatedAt"
+ ? sortField
+ : "updatedAt";
+ const parsedSortDirection: SortDirection =
+ sortDirection === "asc" || sortDirection === "desc"
+ ? sortDirection
+ : parsedSortField === "name"
+ ? "asc"
+ : "desc";
+
+ const rawLimit = limit ? Number.parseInt(limit as string, 10) : undefined;
+ const rawOffset = offset ? Number.parseInt(offset as string, 10) : undefined;
+ const parsedLimit =
+ rawLimit !== undefined && Number.isFinite(rawLimit)
+ ? Math.min(Math.max(rawLimit, 1), MAX_PAGE_SIZE)
+ : undefined;
+ const parsedOffset =
+ rawOffset !== undefined && Number.isFinite(rawOffset) ? Math.max(rawOffset, 0) : undefined;
+
+ const baseRows = await prisma.$queryRaw>(Prisma.sql`
+ SELECT
+ d."id" AS id,
+ d."name" AS name,
+ d."collectionId" AS "collectionId",
+ d."preview" AS preview,
+ d."version" AS version,
+ d."createdAt" AS "createdAt",
+ d."updatedAt" AS "updatedAt",
+ d."elements" AS elements,
+ d."appState" AS "appState",
+ d."files" AS files,
+ u."id" AS "ownerId",
+ u."name" AS "ownerName",
+ u."email" AS "ownerEmail",
+ MAX(CASE g."role" WHEN 'editor' THEN 2 WHEN 'viewer' THEN 1 ELSE 0 END) AS "roleRank"
+ FROM "DrawingShareGrant" g
+ JOIN "Drawing" d ON d."id" = g."drawingId"
+ JOIN "User" u ON u."id" = d."userId"
+ WHERE g."userId" = ${req.user.id}
+ AND d."userId" <> ${req.user.id}
+ AND g."role" IN ('viewer', 'editor')
+ ${searchTerm ? Prisma.sql`AND d."name" LIKE ${`%${searchTerm}%`}` : Prisma.empty}
+ GROUP BY d."id", u."id"
+ `);
+
+ const sortedRows = [...baseRows].sort((a, b) => {
+ const direction = parsedSortDirection === "asc" ? 1 : -1;
+ if (parsedSortField === "name") {
+ return a.name.localeCompare(b.name) * direction;
+ }
+ if (parsedSortField === "createdAt") {
+ return (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) * direction;
+ }
+ return (new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) * direction;
+ });
+
+ const totalCount = sortedRows.length;
+ const start = parsedOffset ?? 0;
+ const end = parsedLimit !== undefined ? start + parsedLimit : undefined;
+ const pageRows = sortedRows.slice(start, end);
+
+ const payload = pageRows.map((row) => {
+ const accessRole = Number(row.roleRank) >= 2 ? "editor" : "viewer";
+ const base = {
+ id: row.id,
+ name: row.name,
+ preview: row.preview,
+ version: row.version,
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt,
+ collectionId: null,
+ accessRole,
+ owner: {
+ id: row.ownerId,
+ name: row.ownerName,
+ email: row.ownerEmail,
+ },
+ } as Record;
+
+ if (shouldIncludeData) {
+ base.elements = parseJsonField(row.elements, []);
+ base.appState = parseJsonField(row.appState, {});
+ base.files = parseJsonField(row.files, {});
+ }
+
+ return base;
+ });
+
+ return res.json({
+ drawings: payload,
+ totalCount,
+ limit: parsedLimit,
+ offset: parsedOffset,
+ });
+ }));
+
+ app.get("/drawings/:id/share-links", requireAuth, asyncHandler(async (req, res) => {
+ if (!req.user) return res.status(401).json({ error: "Unauthorized" });
+
+ const authEnabled = await authModeService.getAuthEnabled();
+ if (!authEnabled) {
+ return res.status(404).json({ error: "Not found" });
+ }
+
+ const id = getRouteIdParam(req.params.id);
+ if (!id) {
+ return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
+ }
+
+ const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id }, select: { id: true } });
+ if (!drawing) {
+ return res.status(404).json({ error: "Drawing not found" });
+ }
+
+ const [viewer, editor] = await Promise.all([
+ ensureShareLinkForRole(prisma, id, "viewer"),
+ ensureShareLinkForRole(prisma, id, "editor"),
+ ]);
+
+ return res.json({
+ drawingId: id,
+ viewerToken: viewer.token,
+ editorToken: editor.token,
+ });
+ }));
+
+ app.post("/drawings/:id/share-links/:role/rotate", requireAuth, asyncHandler(async (req, res) => {
+ if (!req.user) return res.status(401).json({ error: "Unauthorized" });
+
+ const authEnabled = await authModeService.getAuthEnabled();
+ if (!authEnabled) {
+ return res.status(404).json({ error: "Not found" });
+ }
+
+ const id = getRouteIdParam(req.params.id);
+ if (!id) {
+ return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
+ }
+
+ const roleParam = getRouteIdParam(req.params.role);
+ if (!isShareRole(roleParam)) {
+ return res.status(400).json({ error: "Validation error", message: "Invalid share role" });
+ }
+
+ const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id }, select: { id: true } });
+ if (!drawing) {
+ return res.status(404).json({ error: "Drawing not found" });
+ }
+
+ const rotated = await rotateShareLinkForRole(prisma, id, roleParam);
+
+ if (config.enableAuditLogging) {
+ await logAuditEvent({
+ userId: req.user.id,
+ action: "drawing_share_link_rotated",
+ resource: `drawing:${id}`,
+ ipAddress: req.ip || req.connection.remoteAddress || undefined,
+ userAgent: req.headers["user-agent"] || undefined,
+ details: { drawingId: id, role: roleParam },
+ });
+ }
+
+ return res.json({
+ role: roleParam,
+ drawingId: id,
+ token: rotated.token,
+ });
+ }));
+
app.get("/drawings/:id", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const id = getRouteIdParam(req.params.id);
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
- const drawing = await prisma.drawing.findFirst({
- where: {
- id,
- userId: req.user.id,
- },
+
+ const access = await resolveDrawingAccess({
+ prisma,
+ drawingId: id,
+ userId: req.user.id,
+ shareToken: getShareTokenFromRequest(req),
});
- if (!drawing) {
+
+ if (!access) {
return res.status(404).json({ error: "Drawing not found", message: "Drawing does not exist" });
}
+ if (access.tokenRedeemedRole && config.enableAuditLogging) {
+ await logAuditEvent({
+ userId: req.user.id,
+ action: "drawing_share_token_redeemed",
+ resource: `drawing:${id}`,
+ ipAddress: req.ip || req.connection.remoteAddress || undefined,
+ userAgent: req.headers["user-agent"] || undefined,
+ details: { drawingId: id, role: access.tokenRedeemedRole },
+ });
+ }
+
return res.json({
- ...drawing,
- collectionId: toPublicTrashCollectionId(drawing.collectionId, req.user.id),
- elements: parseJsonField(drawing.elements, []),
- appState: parseJsonField(drawing.appState, {}),
- files: parseJsonField(drawing.files, {}),
+ ...access.drawing,
+ accessRole: access.role,
+ collectionId:
+ access.role === "owner"
+ ? toPublicTrashCollectionId(access.drawing.collectionId, req.user.id)
+ : null,
+ elements: parseJsonField(access.drawing.elements, []),
+ appState: parseJsonField(access.drawing.appState, {}),
+ files: parseJsonField(access.drawing.files, {}),
});
}));
@@ -254,6 +499,7 @@ export const registerDrawingRoutes = (
return res.json({
...newDrawing,
+ accessRole: "owner",
collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id),
elements: parseJsonField(newDrawing.elements, []),
appState: parseJsonField(newDrawing.appState, {}),
@@ -266,10 +512,13 @@ export const registerDrawingRoutes = (
const id = getRouteIdParam(req.params.id);
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
- const existingDrawing = await prisma.drawing.findFirst({
- where: { id, userId: req.user.id },
+
+ const access = await resolveDrawingAccess({
+ prisma,
+ drawingId: id,
+ userId: req.user.id,
});
- if (!existingDrawing) return res.status(404).json({ error: "Drawing not found" });
+ if (!access) return res.status(404).json({ error: "Drawing not found" });
const parsed = drawingUpdateSchema.safeParse(req.body);
if (!parsed.success) {
@@ -288,6 +537,17 @@ export const registerDrawingRoutes = (
files?: Record;
version?: number;
};
+
+ const payloadRecord = payload as unknown as Record;
+
+ if (!isAtLeastRole(access.role, "editor")) {
+ return res.status(403).json({ error: "Forbidden", message: "You do not have edit access" });
+ }
+
+ if (access.role === "editor" && !payloadHasOnlySceneFields(payloadRecord)) {
+ return res.status(403).json({ error: "Forbidden", message: "Editors can only update scene content" });
+ }
+
const trashCollectionId = getUserTrashCollectionId(req.user.id);
const isSceneUpdate =
payload.elements !== undefined ||
@@ -304,6 +564,9 @@ export const registerDrawingRoutes = (
if (payload.preview !== undefined) data.preview = payload.preview;
if (payload.collectionId !== undefined) {
+ if (access.role !== "owner") {
+ return res.status(403).json({ error: "Forbidden", message: "Only the owner can move drawings" });
+ }
if (payload.collectionId === "trash") {
await ensureTrashCollection(prisma, req.user.id);
(data as Prisma.DrawingUncheckedUpdateInput).collectionId = trashCollectionId;
@@ -318,7 +581,10 @@ export const registerDrawingRoutes = (
}
}
- const updateWhere: Prisma.DrawingWhereInput = { id, userId: req.user.id };
+ const updateWhere: Prisma.DrawingWhereInput = { id };
+ if (access.role === "owner") {
+ updateWhere.userId = req.user.id;
+ }
if (isSceneUpdate && payload.version !== undefined) {
updateWhere.version = payload.version;
}
@@ -330,7 +596,7 @@ export const registerDrawingRoutes = (
if (updateResult.count === 0) {
if (isSceneUpdate && payload.version !== undefined) {
const latestDrawing = await prisma.drawing.findFirst({
- where: { id, userId: req.user.id },
+ where: { id },
select: { version: true },
});
return res.status(409).json({
@@ -344,7 +610,7 @@ export const registerDrawingRoutes = (
}
const updatedDrawing = await prisma.drawing.findFirst({
- where: { id, userId: req.user.id },
+ where: { id },
});
if (!updatedDrawing) {
return res.status(404).json({ error: "Drawing not found" });
@@ -353,7 +619,11 @@ export const registerDrawingRoutes = (
return res.json({
...updatedDrawing,
- collectionId: toPublicTrashCollectionId(updatedDrawing.collectionId, req.user.id),
+ accessRole: access.role,
+ collectionId:
+ access.role === "owner"
+ ? toPublicTrashCollectionId(updatedDrawing.collectionId, req.user.id)
+ : null,
elements: parseJsonField(updatedDrawing.elements, []),
appState: parseJsonField(updatedDrawing.appState, {}),
files: parseJsonField(updatedDrawing.files, {}),
@@ -395,20 +665,26 @@ export const registerDrawingRoutes = (
const id = getRouteIdParam(req.params.id);
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
- const original = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
- if (!original) return res.status(404).json({ error: "Original drawing not found" });
- let duplicatedCollectionId = original.collectionId;
- if (isTrashCollectionId(original.collectionId, req.user.id)) {
+
+ const access = await resolveDrawingAccess({
+ prisma,
+ drawingId: id,
+ userId: req.user.id,
+ });
+ if (!access) return res.status(404).json({ error: "Original drawing not found" });
+
+ let duplicatedCollectionId = access.role === "owner" ? access.drawing.collectionId : null;
+ if (access.role === "owner" && isTrashCollectionId(access.drawing.collectionId, req.user.id)) {
await ensureTrashCollection(prisma, req.user.id);
duplicatedCollectionId = getUserTrashCollectionId(req.user.id);
}
const newDrawing = await prisma.drawing.create({
data: {
- name: `${original.name} (Copy)`,
- elements: original.elements,
- appState: original.appState,
- files: original.files,
+ name: `${access.drawing.name} (Copy)`,
+ elements: access.drawing.elements,
+ appState: access.drawing.appState,
+ files: access.drawing.files,
userId: req.user.id,
collectionId: duplicatedCollectionId,
version: 1,
@@ -418,6 +694,7 @@ export const registerDrawingRoutes = (
return res.json({
...newDrawing,
+ accessRole: "owner",
collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id),
elements: parseJsonField(newDrawing.elements, []),
appState: parseJsonField(newDrawing.appState, {}),
diff --git a/backend/src/routes/dashboard/types.ts b/backend/src/routes/dashboard/types.ts
index 920af0f..103be14 100644
--- a/backend/src/routes/dashboard/types.ts
+++ b/backend/src/routes/dashboard/types.ts
@@ -1,6 +1,7 @@
import express from "express";
import { z } from "zod";
import { Prisma, PrismaClient } from "../../generated/client";
+import { AuthModeService } from "../../auth/authMode";
export type SortField = "name" | "createdAt" | "updatedAt";
export type SortDirection = "asc" | "desc";
@@ -30,6 +31,7 @@ type LogAuditEvent = (params: {
export type DashboardRouteDeps = {
prisma: PrismaClient;
+ authModeService: AuthModeService;
requireAuth: express.RequestHandler;
asyncHandler: (
fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise
diff --git a/backend/src/server/drawingAccess.ts b/backend/src/server/drawingAccess.ts
new file mode 100644
index 0000000..d7a75b6
--- /dev/null
+++ b/backend/src/server/drawingAccess.ts
@@ -0,0 +1,244 @@
+import { randomBytes, randomUUID } from "crypto";
+import { Prisma, PrismaClient } from "../generated/client";
+
+export const SHARE_ROLES = ["viewer", "editor"] as const;
+export type ShareRole = (typeof SHARE_ROLES)[number];
+export type DrawingAccessRole = "owner" | ShareRole;
+
+const SHARE_ROLE_SET = new Set(SHARE_ROLES);
+
+export const isShareRole = (value: unknown): value is ShareRole =>
+ typeof value === "string" && SHARE_ROLE_SET.has(value);
+
+const roleRank = (role: DrawingAccessRole): number => {
+ if (role === "owner") return 3;
+ if (role === "editor") return 2;
+ return 1;
+};
+
+export const isAtLeastRole = (
+ grantedRole: DrawingAccessRole,
+ requiredRole: DrawingAccessRole
+): boolean => roleRank(grantedRole) >= roleRank(requiredRole);
+
+export const generateShareToken = (): string => randomBytes(24).toString("hex");
+
+export type DrawingAccessResult = {
+ drawing: {
+ id: string;
+ userId: string;
+ name: string;
+ elements: string;
+ appState: string;
+ files: string;
+ preview: string | null;
+ version: number;
+ collectionId: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ };
+ role: DrawingAccessRole;
+ tokenRedeemedRole?: ShareRole;
+};
+
+type ResolveDrawingAccessParams = {
+ prisma: PrismaClient;
+ drawingId: string;
+ userId: string;
+ shareToken?: string;
+};
+
+type RawShareLink = {
+ id: string;
+ drawingId: string;
+ role: string;
+};
+
+type RawShareGrant = {
+ role: string;
+};
+
+const upsertShareGrant = async (
+ prisma: PrismaClient,
+ params: {
+ drawingId: string;
+ userId: string;
+ shareLinkId: string;
+ role: ShareRole;
+ }
+): Promise => {
+ const now = new Date().toISOString();
+ await prisma.$executeRaw`
+ INSERT OR IGNORE INTO "DrawingShareGrant"
+ ("id", "drawingId", "userId", "shareLinkId", "role", "createdAt", "updatedAt")
+ VALUES
+ (${randomUUID()}, ${params.drawingId}, ${params.userId}, ${params.shareLinkId}, ${params.role}, ${now}, ${now})
+ `;
+
+ await prisma.$executeRaw`
+ UPDATE "DrawingShareGrant"
+ SET "role" = ${params.role}, "updatedAt" = ${now}
+ WHERE "drawingId" = ${params.drawingId}
+ AND "userId" = ${params.userId}
+ AND "shareLinkId" = ${params.shareLinkId}
+ `;
+};
+
+export const resolveDrawingAccess = async ({
+ prisma,
+ drawingId,
+ userId,
+ shareToken,
+}: ResolveDrawingAccessParams): Promise => {
+ const drawing = await prisma.drawing.findUnique({
+ where: { id: drawingId },
+ });
+ if (!drawing) return null;
+
+ if (drawing.userId === userId) {
+ return { drawing, role: "owner" };
+ }
+
+ let tokenRedeemedRole: ShareRole | undefined;
+ const token = typeof shareToken === "string" && shareToken.trim().length > 0
+ ? shareToken.trim()
+ : undefined;
+
+ if (token) {
+ const links = await prisma.$queryRaw`
+ SELECT "id", "drawingId", "role"
+ FROM "DrawingShareLink"
+ WHERE "token" = ${token}
+ LIMIT 1
+ `;
+ const link = links[0];
+
+ if (link && link.drawingId === drawingId && isShareRole(link.role)) {
+ await upsertShareGrant(prisma, {
+ drawingId,
+ userId,
+ shareLinkId: link.id,
+ role: link.role,
+ });
+ tokenRedeemedRole = link.role;
+ }
+ }
+
+ const grants = await prisma.$queryRaw`
+ SELECT "role"
+ FROM "DrawingShareGrant"
+ WHERE "drawingId" = ${drawingId}
+ AND "userId" = ${userId}
+ `;
+
+ const hasEditor = grants.some((grant) => grant.role === "editor");
+ if (hasEditor) return { drawing, role: "editor", tokenRedeemedRole };
+
+ const hasViewer = grants.some((grant) => grant.role === "viewer");
+ if (hasViewer) return { drawing, role: "viewer", tokenRedeemedRole };
+
+ return null;
+};
+
+export const ensureShareLinkForRole = async (
+ prisma: PrismaClient,
+ drawingId: string,
+ role: ShareRole
+): Promise<{ id: string; token: string }> => {
+ const rows = await prisma.$queryRaw>`
+ SELECT "id", "token"
+ FROM "DrawingShareLink"
+ WHERE "drawingId" = ${drawingId}
+ AND "role" = ${role}
+ LIMIT 1
+ `;
+ const existing = rows[0];
+ if (existing) return existing;
+
+ const token = generateShareToken();
+ const now = new Date().toISOString();
+ const id = randomUUID();
+
+ await prisma.$executeRaw`
+ INSERT INTO "DrawingShareLink"
+ ("id", "drawingId", "role", "token", "createdAt", "updatedAt")
+ VALUES
+ (${id}, ${drawingId}, ${role}, ${token}, ${now}, ${now})
+ `;
+
+ return { id, token };
+};
+
+export const rotateShareLinkForRole = async (
+ prisma: PrismaClient,
+ drawingId: string,
+ role: ShareRole
+): Promise<{ token: string }> => {
+ return prisma.$transaction(async (tx) => {
+ const existing = await tx.$queryRaw>`
+ SELECT "id"
+ FROM "DrawingShareLink"
+ WHERE "drawingId" = ${drawingId}
+ AND "role" = ${role}
+ LIMIT 1
+ `;
+
+ const now = new Date().toISOString();
+ const token = generateShareToken();
+
+ if (!existing[0]) {
+ await tx.$executeRaw`
+ INSERT INTO "DrawingShareLink"
+ ("id", "drawingId", "role", "token", "createdAt", "updatedAt")
+ VALUES
+ (${randomUUID()}, ${drawingId}, ${role}, ${token}, ${now}, ${now})
+ `;
+ return { token };
+ }
+
+ const linkId = existing[0].id;
+
+ await tx.$executeRaw`
+ DELETE FROM "DrawingShareGrant"
+ WHERE "drawingId" = ${drawingId}
+ AND "shareLinkId" = ${linkId}
+ `;
+
+ await tx.$executeRaw`
+ UPDATE "DrawingShareLink"
+ SET "token" = ${token}, "updatedAt" = ${now}
+ WHERE "id" = ${linkId}
+ `;
+
+ return { token };
+ });
+};
+
+export const getSharedRoleMap = async (
+ prisma: PrismaClient,
+ userId: string,
+ drawingIds: string[]
+): Promise
e.stopPropagation()}>
-
+ {canManage && (
+
+ )}
{showCollectionDropdown && (
<>
@@ -387,56 +393,60 @@ export const DrawingCard: React.FC
= ({
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
-
-
- setShowMoveSubmenu(true)}
- onMouseLeave={() => setShowMoveSubmenu(false)}
- >
+ {canManage && (
+ )}
- {showMoveSubmenu && (
-
-
- {collections.map(c => (
+ {canManage && (
+
setShowMoveSubmenu(true)}
+ onMouseLeave={() => setShowMoveSubmenu(false)}
+ >
+
+
+ {showMoveSubmenu && (
+
- ))}
-
- )}
-
+ {collections.map(c => (
+
+ ))}
+
+ )}
+
+ )}
@@ -468,17 +478,20 @@ export const DrawingCard: React.FC = ({
)}
-
-
-
+ {canManage && (
+ <>
+
+
+ >
+ )}
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index b98f9cb..eb533e2 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
-import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, User, LogOut, Shield } from 'lucide-react';
+import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, User, LogOut, Shield, Users } from 'lucide-react';
import type { Collection } from '../types';
import clsx from 'clsx';
import { ConfirmModal } from './ConfirmModal';
@@ -207,6 +207,23 @@ export const Sidebar: React.FC = ({
onClick={() => onSelectCollection(null)}
onDrop={onDrop}
/>
+
+ {authEnabled && (
+
+
+
+ )}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index e9fb6a5..1b47392 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -21,6 +21,7 @@ export const Dashboard: React.FC = () => {
const selectedCollectionId = React.useMemo(() => {
if (location.pathname === '/') return undefined;
+ if (location.pathname === '/shared') return '__shared__';
if (location.pathname === '/collections') {
const id = searchParams.get('id');
if (id === 'unorganized') return null;
@@ -32,6 +33,8 @@ export const Dashboard: React.FC = () => {
const setSelectedCollectionId = (id: string | null | undefined) => {
if (id === undefined) {
navigate('/');
+ } else if (id === '__shared__') {
+ navigate('/shared');
} else if (id === null) {
navigate('/collections?id=unorganized');
} else {
@@ -39,6 +42,8 @@ export const Dashboard: React.FC = () => {
}
};
+ const isSharedView = selectedCollectionId === '__shared__';
+
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const [selectedIds, setSelectedIds] = useState
>(new Set());
@@ -265,8 +270,13 @@ export const Dashboard: React.FC = () => {
const currentSortOption = sortOptions.find(opt => opt.field === sortConfig.field) || sortOptions[0];
const isTrashView = selectedCollectionId === 'trash';
+ const canManageDrawing = useCallback(
+ (id: string) => (drawings.find((d) => d.id === id)?.accessRole ?? 'owner') === 'owner',
+ [drawings]
+ );
+
const handleCreateDrawing = async () => {
- if (isTrashView) return;
+ if (isTrashView || isSharedView) return;
try {
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
const { id } = await api.createDrawing('Untitled Drawing', targetCollectionId);
@@ -277,7 +287,7 @@ export const Dashboard: React.FC = () => {
};
const handleImportDrawings = async (files: FileList | null) => {
- if (!files || isTrashView) return;
+ if (!files || isTrashView || isSharedView) return;
const fileArray = Array.from(files);
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
@@ -290,6 +300,7 @@ export const Dashboard: React.FC = () => {
};
const handleRenameDrawing = async (id: string, name: string) => {
+ if (!canManageDrawing(id)) return;
setDrawings(prev => prev.map(d => d.id === id ? { ...d, name } : d));
try {
await api.updateDrawing(id, { name });
@@ -300,6 +311,7 @@ export const Dashboard: React.FC = () => {
};
const handleDeleteDrawing = async (id: string) => {
+ if (!canManageDrawing(id)) return;
if (isTrashView) {
// Permanent Delete -> Confirm first
setDrawingToDelete(id);
@@ -327,6 +339,7 @@ export const Dashboard: React.FC = () => {
};
const executePermanentDelete = async (id: string) => {
+ if (!canManageDrawing(id)) return;
setDrawings(prev => {
const next = prev.filter(d => d.id !== id);
if (next.length !== prev.length) {
@@ -378,6 +391,8 @@ export const Dashboard: React.FC = () => {
const handleBulkDeleteClick = () => {
if (selectedIds.size === 0) return;
+ const ownerSelectedCount = Array.from(selectedIds).filter((id) => canManageDrawing(id)).length;
+ if (ownerSelectedCount === 0) return;
if (isTrashView) {
setShowBulkDeleteConfirm(true);
} else {
@@ -387,10 +402,12 @@ export const Dashboard: React.FC = () => {
const executeBulkMoveToTrash = async () => {
const trashId = 'trash';
- const ids = Array.from(selectedIds);
+ const ids = Array.from(selectedIds).filter((id) => canManageDrawing(id));
+ if (ids.length === 0) return;
setDrawings(prev => {
- const next = prev.filter(d => !selectedIds.has(d.id));
+ const idSet = new Set(ids);
+ const next = prev.filter(d => !idSet.has(d.id));
setTotalCount(t => t - (prev.length - next.length));
return next;
});
@@ -405,9 +422,11 @@ export const Dashboard: React.FC = () => {
};
const executeBulkPermanentDelete = async () => {
- const ids = Array.from(selectedIds);
+ const ids = Array.from(selectedIds).filter((id) => canManageDrawing(id));
+ if (ids.length === 0) return;
setDrawings(prev => {
- const next = prev.filter(d => !selectedIds.has(d.id));
+ const idSet = new Set(ids);
+ const next = prev.filter(d => !idSet.has(d.id));
setTotalCount(t => t - (prev.length - next.length));
return next;
});
@@ -425,11 +444,13 @@ export const Dashboard: React.FC = () => {
const handleBulkMove = async (collectionId: string | null) => {
if (selectedIds.size === 0) return;
- const idsToMove = Array.from(selectedIds);
+ const idsToMove = Array.from(selectedIds).filter((id) => canManageDrawing(id));
+ if (idsToMove.length === 0) return;
+ const idsToMoveSet = new Set(idsToMove);
// Optimistic update
setDrawings(prev => {
- const updated = prev.map(d => selectedIds.has(d.id) ? { ...d, collectionId } : d);
+ const updated = prev.map(d => idsToMoveSet.has(d.id) ? { ...d, collectionId } : d);
if (selectedCollectionId === undefined) return updated;
const next = updated.filter(d => {
if (selectedCollectionId === null) return d.collectionId === null;
@@ -472,6 +493,7 @@ export const Dashboard: React.FC = () => {
};
const handleMoveToCollection = async (id: string, collectionId: string | null) => {
+ if (!canManageDrawing(id)) return;
setDrawings(prev => {
const updated = prev.map(d => d.id === id ? { ...d, collectionId } : d);
const next = updated.filter(d => {
@@ -530,6 +552,7 @@ export const Dashboard: React.FC = () => {
const viewTitle = React.useMemo(() => {
if (selectedCollectionId === undefined) return "All Drawings";
if (selectedCollectionId === null) return "Unorganized";
+ if (selectedCollectionId === '__shared__') return "Shared with me";
if (selectedCollectionId === 'trash') return "Trash";
const collection = collections.find(c => c.id === selectedCollectionId);
return collection ? collection.name : "Collection";
@@ -537,6 +560,10 @@ export const Dashboard: React.FC = () => {
const hasSelection = selectedIds.size > 0;
const allSelected = sortedDrawings.length > 0 && selectedIds.size === sortedDrawings.length;
+ const manageableSelectionCount = React.useMemo(
+ () => Array.from(selectedIds).filter((id) => canManageDrawing(id)).length,
+ [selectedIds, canManageDrawing]
+ );
const handleSelectAll = () => {
if (allSelected) {
@@ -567,7 +594,7 @@ export const Dashboard: React.FC = () => {
}
const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib'));
- if (drawingFiles.length > 0) {
+ if (drawingFiles.length > 0 && !isSharedView) {
uploadFiles(drawingFiles, targetCollectionId).finally(() => {
refreshData();
});
@@ -588,6 +615,9 @@ export const Dashboard: React.FC = () => {
idsToMove.add(draggedDrawingId);
}
+ idsToMove = new Set(Array.from(idsToMove).filter((id) => canManageDrawing(id)));
+ if (idsToMove.size === 0) return;
+
// Optimistic Update
setDrawings(prev => {
const updated = prev.map(d => idsToMove.has(d.id) ? { ...d, collectionId: targetCollectionId } : d);
@@ -805,10 +835,10 @@ export const Dashboard: React.FC = () => {
- {showBulkMoveMenu && hasSelection && (
+ {showBulkMoveMenu && manageableSelectionCount > 0 && (
<>
setShowBulkMoveMenu(false)} />
- Move {selectedIds.size} items to...
+ Move {manageableSelectionCount} items to...
handleBulkMove(null)}
@@ -891,10 +921,10 @@ export const Dashboard: React.FC = () => {
document.getElementById('dashboard-import')?.click()}
- disabled={isTrashView}
+ disabled={isTrashView || isSharedView}
className={clsx(
"h-[42px] w-full sm:w-auto flex items-center justify-center gap-2 px-6 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] transition-all font-bold text-sm whitespace-nowrap",
- isTrashView
+ isTrashView || isSharedView
? "bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-600 border-slate-300 dark:border-slate-700 shadow-none cursor-not-allowed"
: "bg-emerald-600 dark:bg-neutral-800 text-white hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 active:translate-y-0 active:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:active:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
)}
@@ -905,10 +935,10 @@ export const Dashboard: React.FC = () => {
{
}
}}
onMouseDown={handleCardMouseDown}
- onDragStart={handleCardDragStart}
+ onDragStart={(drawing.accessRole ?? 'owner') === 'owner' ? handleCardDragStart : undefined}
onPreviewGenerated={handlePreviewGenerated}
+ canManage={(drawing.accessRole ?? 'owner') === 'owner'}
/>
))
)}
diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx
index 8324bb4..2c16b5e 100644
--- a/frontend/src/pages/Editor.tsx
+++ b/frontend/src/pages/Editor.tsx
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
-import { ArrowLeft, Download, Loader2, ChevronUp, ChevronDown } from 'lucide-react';
+import { ArrowLeft, Download, Loader2, ChevronUp, ChevronDown, Share2, Copy, RotateCcw } from 'lucide-react';
import clsx from 'clsx';
import { Excalidraw, exportToSvg } from '@excalidraw/excalidraw';
import debounce from 'lodash/debounce';
@@ -12,8 +12,10 @@ import { useAuth } from '../context/AuthContext';
import { reconcileElements } from '../utils/sync';
import { exportFromEditor } from '../utils/exportUtils';
import * as api from '../api';
+import type { ShareLinkRole } from '../api';
import { useTheme } from '../context/ThemeContext';
import {
+ buildElementSyncFingerprint,
UIOptions,
getFilesDelta,
hasRenderableElements,
@@ -25,6 +27,7 @@ import {
import type { ElementVersionInfo } from './editor/shared';
import { useEditorChrome } from './editor/useEditorChrome';
import { useEditorIdentity } from './editor/useEditorIdentity';
+import type { DrawingAccessRole } from '../types';
interface Peer extends UserIdentity {
isActive: boolean;
@@ -49,6 +52,11 @@ export const Editor: React.FC = () => {
const [isSceneLoading, setIsSceneLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [isSavingOnLeave, setIsSavingOnLeave] = useState(false);
+ const [accessRole, setAccessRole] = useState('owner');
+ const [showShareModal, setShowShareModal] = useState(false);
+ const [shareLinks, setShareLinks] = useState<{ viewer: string; editor: string } | null>(null);
+ const [isLoadingShareLinks, setIsLoadingShareLinks] = useState(false);
+ const [rotatingRole, setRotatingRole] = useState(null);
const [autoHideEnabled, setAutoHideEnabled] = useState(true);
const { isHeaderVisible, setIsHeaderVisible } = useEditorChrome({
drawingName,
@@ -56,11 +64,19 @@ export const Editor: React.FC = () => {
isRenaming,
});
const me: UserIdentity = useEditorIdentity(user);
+ const canEditScene = accessRole !== 'viewer';
+ const isOwner = accessRole === 'owner';
+ const sharingAvailable = isOwner && Boolean(user);
+ const toShareUrl = useCallback((drawingId: string, token: string) => {
+ return `${window.location.origin}/editor/${drawingId}#share=${token}`;
+ }, []);
const [peers, setPeers] = useState([]);
const [isReady, setIsReady] = useState(false);
const socketRef = useRef(null);
const lastCursorEmit = useRef(0);
+ const lastPointerButtonRef = useRef("up");
+ const lastPointerSelectionSigRef = useRef("{}");
const elementVersionMap = useRef
+ {sharingAvailable && (
+ <>
+
+ setShowShareModal(true)}
+ className="p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 rounded-lg text-gray-600 dark:text-gray-300 transition-colors"
+ title="Share drawing"
+ >
+
+
+ >
+ )}
+
@@ -1338,6 +1535,7 @@ export const Editor: React.FC = () => {
onLibraryChange={handleLibraryChange}
excalidrawAPI={setExcalidrawAPI}
UIOptions={UIOptions}
+ viewModeEnabled={!canEditScene}
/>
) : (
@@ -1347,6 +1545,71 @@ export const Editor: React.FC = () => {
)}
+
+ {showShareModal && (
+
setShowShareModal(false)}
+ >
+
e.stopPropagation()}
+ >
+
Share Drawing
+
+ Anyone with a link must still be logged in to access this drawing.
+
+
+ {isLoadingShareLinks || !shareLinks ? (
+
+
+ Loading share links...
+
+ ) : (
+
+ {(['viewer', 'editor'] as ShareLinkRole[]).map((role) => (
+
+
+ {role}
+ handleRotateShareLink(role)}
+ disabled={rotatingRole === role}
+ className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-gray-300 dark:border-neutral-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-neutral-800 disabled:opacity-50"
+ >
+ {rotatingRole === role ? : }
+ Rotate
+
+
+
+
+ handleCopyShareLink(role)}
+ className="inline-flex items-center gap-1 text-xs px-2 py-2 rounded-md border border-gray-300 dark:border-neutral-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-neutral-800"
+ >
+
+ Copy
+
+
+
+ ))}
+
+ )}
+
+
+ setShowShareModal(false)}
+ className="px-4 py-2 rounded-lg border-2 border-black dark:border-neutral-700 bg-white dark:bg-neutral-900 text-gray-900 dark:text-gray-100 font-semibold hover:bg-gray-50 dark:hover:bg-neutral-800 transition-colors"
+ >
+ Close
+
+
+
+
+ )}
);
diff --git a/frontend/src/pages/dashboard/useDashboardData.ts b/frontend/src/pages/dashboard/useDashboardData.ts
index fd81a91..6e5810d 100644
--- a/frontend/src/pages/dashboard/useDashboardData.ts
+++ b/frontend/src/pages/dashboard/useDashboardData.ts
@@ -36,13 +36,22 @@ export const useDashboardData = ({
const requestVersion = ++listRequestVersionRef.current;
setIsLoading(true);
try {
+ const isSharedView = selectedCollectionId === '__shared__';
const [drawingsRes, collectionsData] = await Promise.all([
- api.getDrawings(debouncedSearch, selectedCollectionId, {
- limit: pageSize,
- offset: 0,
- sortField,
- sortDirection,
- }),
+ isSharedView
+ ? api.getSharedDrawings({
+ search: debouncedSearch || undefined,
+ limit: pageSize,
+ offset: 0,
+ sortField,
+ sortDirection,
+ })
+ : api.getDrawings(debouncedSearch, selectedCollectionId, {
+ limit: pageSize,
+ offset: 0,
+ sortField,
+ sortDirection,
+ }),
api.getCollections(),
]);
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
@@ -71,12 +80,21 @@ export const useDashboardData = ({
const requestVersion = listRequestVersionRef.current;
setIsFetchingMore(true);
try {
- const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, {
- limit: pageSize,
- offset: drawings.length,
- sortField,
- sortDirection,
- });
+ const isSharedView = selectedCollectionId === '__shared__';
+ const drawingsRes = isSharedView
+ ? await api.getSharedDrawings({
+ search: debouncedSearch || undefined,
+ limit: pageSize,
+ offset: drawings.length,
+ sortField,
+ sortDirection,
+ })
+ : await api.getDrawings(debouncedSearch, selectedCollectionId, {
+ limit: pageSize,
+ offset: drawings.length,
+ sortField,
+ sortDirection,
+ });
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
setDrawings((prev) => mergeUniqueDrawings(prev, drawingsRes.drawings));
setTotalCount(drawingsRes.totalCount);
diff --git a/frontend/src/pages/editor/shared.ts b/frontend/src/pages/editor/shared.ts
index 1973c65..64e10d4 100644
--- a/frontend/src/pages/editor/shared.ts
+++ b/frontend/src/pages/editor/shared.ts
@@ -1,8 +1,35 @@
export interface ElementVersionInfo {
version: number;
versionNonce: number;
+ updated?: number;
+ syncFingerprint?: string;
}
+export const buildElementSyncFingerprint = (element: any): string => {
+ if (!element || typeof element !== "object") return "";
+
+ const points = Array.isArray(element.points) ? element.points : null;
+ const firstPoint = points && points.length > 0 ? points[0] : null;
+ const lastPoint = points && points.length > 0 ? points[points.length - 1] : null;
+
+ return JSON.stringify({
+ id: element.id ?? null,
+ type: element.type ?? null,
+ isDeleted: Boolean(element.isDeleted),
+ x: element.x ?? null,
+ y: element.y ?? null,
+ width: element.width ?? null,
+ height: element.height ?? null,
+ angle: element.angle ?? null,
+ startBinding: element.startBinding?.elementId ?? null,
+ endBinding: element.endBinding?.elementId ?? null,
+ pointsLen: points ? points.length : null,
+ firstPoint,
+ lastPoint,
+ text: typeof element.text === "string" ? element.text : null,
+ });
+};
+
export const haveSameElements = (a: readonly any[] = [], b: readonly any[] = []) => {
if (!a || !b) return false;
if (a.length !== b.length) return false;
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index e7eca7f..f97bfef 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -1,3 +1,11 @@
+export type DrawingAccessRole = "owner" | "editor" | "viewer";
+
+export interface DrawingOwner {
+ id: string;
+ name: string;
+ email?: string;
+}
+
export interface DrawingSummary {
id: string;
name: string;
@@ -6,6 +14,8 @@ export interface DrawingSummary {
createdAt: number;
version: number;
preview?: string | null;
+ accessRole?: DrawingAccessRole;
+ owner?: DrawingOwner;
}
export interface Drawing extends DrawingSummary {
diff --git a/frontend/src/utils/sync.ts b/frontend/src/utils/sync.ts
index 38ecf49..51cf444 100644
--- a/frontend/src/utils/sync.ts
+++ b/frontend/src/utils/sync.ts
@@ -17,6 +17,14 @@ export const reconcileElements = (
const value = element?.updated;
return typeof value === "number" ? value : Number(value) || 0;
};
+ const getComparableContent = (element: any): string => {
+ if (!element || typeof element !== "object") return "";
+ const copy = { ...element } as Record
;
+ delete copy.version;
+ delete copy.versionNonce;
+ delete copy.updated;
+ return JSON.stringify(copy);
+ };
remoteElements.forEach((remoteEl) => {
const localEl = localMap.get(remoteEl.id);
@@ -51,7 +59,17 @@ export const reconcileElements = (
getVersionNonce(remoteEl) !== getVersionNonce(localEl)
) {
localMap.set(remoteEl.id, remoteEl);
+ return;
}
+
+ if (
+ remoteUpdated === localUpdated &&
+ getVersionNonce(remoteEl) === getVersionNonce(localEl) &&
+ getComparableContent(remoteEl) !== getComparableContent(localEl)
+ ) {
+ localMap.set(remoteEl.id, remoteEl);
+ }
+
});
return Array.from(localMap.values());
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index b2b6506..fb549ff 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -16,22 +16,25 @@ try {
}
const appVersion = process.env.VITE_APP_VERSION?.trim() || versionFromFile;
-const buildLabel = process.env.VITE_APP_BUILD_LABEL?.trim() || "local development build";
+const buildLabel =
+ process.env.VITE_APP_BUILD_LABEL?.trim() || "local development build";
// https://vite.dev/config/
export default defineConfig(({ command }) => {
- const nodeEnv = process.env.NODE_ENV || (command === "build" ? "production" : "development");
+ const nodeEnv =
+ process.env.NODE_ENV ||
+ (command === "build" ? "production" : "development");
const processEnvDefines = {
- 'process.env.IS_PREACT': JSON.stringify("false"),
- 'process.env.NODE_ENV': JSON.stringify(nodeEnv),
+ "process.env.IS_PREACT": JSON.stringify("false"),
+ "process.env.NODE_ENV": JSON.stringify(nodeEnv),
};
return {
plugins: [react()],
define: {
...processEnvDefines,
- 'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion),
- 'import.meta.env.VITE_APP_BUILD_LABEL': JSON.stringify(buildLabel),
+ "import.meta.env.VITE_APP_VERSION": JSON.stringify(appVersion),
+ "import.meta.env.VITE_APP_BUILD_LABEL": JSON.stringify(buildLabel),
},
optimizeDeps: {
esbuildOptions: {