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> => { + const roleByDrawingId = new Map(); + if (drawingIds.length === 0) return roleByDrawingId; + + const idsSql = Prisma.join(drawingIds); + const rows = await prisma.$queryRaw>( + Prisma.sql` + SELECT "drawingId", "role" + FROM "DrawingShareGrant" + WHERE "userId" = ${userId} + AND "drawingId" IN (${idsSql}) + ` + ); + + for (const row of rows) { + if (!isShareRole(row.role)) continue; + const current = roleByDrawingId.get(row.drawingId); + if (!current || (current === "viewer" && row.role === "editor")) { + roleByDrawingId.set(row.drawingId, row.role); + } + } + + return roleByDrawingId; +}; diff --git a/backend/src/server/socket.ts b/backend/src/server/socket.ts index 402d1a0..ce09eaf 100644 --- a/backend/src/server/socket.ts +++ b/backend/src/server/socket.ts @@ -3,6 +3,7 @@ import { Server } from "socket.io"; import { PrismaClient } from "../generated/client"; import { AuthModeService } from "../auth/authMode"; import { ACCESS_TOKEN_COOKIE_NAME, parseCookieHeader } from "../auth/cookies"; +import { isAtLeastRole, resolveDrawingAccess } from "./drawingAccess"; interface User { id: string; @@ -110,7 +111,7 @@ export const registerSocketHandlers = ({ io.on("connection", (socket) => { const authenticatedUserId = socketUserMap.get(socket.id); - const authorizedDrawingIds = new Set(); + const authorizedDrawingRoles = new Map(); socket.on( "join-room", @@ -123,20 +124,22 @@ export const registerSocketHandlers = ({ }) => { try { if (authenticatedUserId) { - const drawing = await prisma.drawing.findFirst({ - where: { id: drawingId, userId: authenticatedUserId }, - select: { id: true }, + const drawing = await resolveDrawingAccess({ + prisma, + drawingId, + userId: authenticatedUserId, }); if (!drawing) { socket.emit("error", { message: "You do not have access to this drawing" }); return; } + + authorizedDrawingRoles.set(drawingId, drawing.role); } const roomId = `drawing_${drawingId}`; socket.join(roomId); - authorizedDrawingIds.add(drawingId); let trustedUserId = typeof user?.id === "string" && user.id.trim().length > 0 @@ -179,7 +182,7 @@ export const registerSocketHandlers = ({ socket.on("cursor-move", (data) => { const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null; - if (!drawingId || !authorizedDrawingIds.has(drawingId)) { + if (!drawingId || !authorizedDrawingRoles.has(drawingId)) { return; } const roomId = `drawing_${drawingId}`; @@ -188,7 +191,11 @@ export const registerSocketHandlers = ({ socket.on("element-update", (data) => { const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null; - if (!drawingId || !authorizedDrawingIds.has(drawingId)) { + if (!drawingId) { + return; + } + const role = authorizedDrawingRoles.get(drawingId); + if (!role || !isAtLeastRole(role, "editor")) { return; } const roomId = `drawing_${drawingId}`; @@ -198,7 +205,7 @@ export const registerSocketHandlers = ({ socket.on( "user-activity", ({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => { - if (!authorizedDrawingIds.has(drawingId)) { + if (!authorizedDrawingRoles.has(drawingId)) { return; } const roomId = `drawing_${drawingId}`; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9685e83..6ee81c8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,6 +52,14 @@ function App() { } /> + + + + } + /> { export type DrawingSortField = "name" | "createdAt" | "updatedAt"; export type SortDirection = "asc" | "desc"; +export type ShareLinkRole = "viewer" | "editor"; export function getDrawings( search?: string, @@ -475,8 +479,46 @@ export async function getDrawings( }; } -export const getDrawing = async (id: string) => { - const response = await api.get(`/drawings/${id}`); +export const getSharedDrawings = async (options?: { + search?: string; + includeData?: boolean; + limit?: number; + offset?: number; + sortField?: DrawingSortField; + sortDirection?: SortDirection; +}) => { + const params: Record = {}; + if (options?.search) params.search = options.search; + if (options?.includeData) params.includeData = "true"; + if (options?.limit !== undefined) params.limit = options.limit; + if (options?.offset !== undefined) params.offset = options.offset; + if (options?.sortField) params.sortField = options.sortField; + if (options?.sortDirection) params.sortDirection = options.sortDirection; + + if (options?.includeData) { + const response = await api.get>("/drawings/shared", { params }); + return { + ...response.data, + drawings: response.data.drawings.map(deserializeDrawing), + }; + } + + const response = await api.get>("/drawings/shared", { params }); + return { + ...response.data, + drawings: response.data.drawings.map(deserializeDrawingSummary), + }; +}; + +export const getDrawing = async ( + id: string, + options?: { shareToken?: string } +) => { + const headers: Record = {}; + if (options?.shareToken) { + headers["x-share-token"] = options.shareToken; + } + const response = await api.get(`/drawings/${id}`, { headers }); return deserializeDrawing(response.data); }; @@ -508,6 +550,23 @@ export const duplicateDrawing = async (id: string) => { return deserializeDrawing(response.data); }; +export const getDrawingShareLinks = async ( + id: string +): Promise<{ drawingId: string; viewerToken: string; editorToken: string }> => { + const response = await api.get<{ drawingId: string; viewerToken: string; editorToken: string }>(`/drawings/${id}/share-links`); + return response.data; +}; + +export const rotateDrawingShareLink = async ( + id: string, + role: ShareLinkRole +): Promise<{ role: ShareLinkRole; drawingId: string; token: string }> => { + const response = await api.post<{ role: ShareLinkRole; drawingId: string; token: string }>( + `/drawings/${id}/share-links/${role}/rotate` + ); + return response.data; +}; + export const getCollections = async () => { const response = await api.get("/collections"); return response.data; diff --git a/frontend/src/components/DrawingCard.tsx b/frontend/src/components/DrawingCard.tsx index fe77ad9..11551f9 100644 --- a/frontend/src/components/DrawingCard.tsx +++ b/frontend/src/components/DrawingCard.tsx @@ -56,6 +56,7 @@ interface DrawingCardProps { onDragStart?: (e: React.DragEvent, id: string) => void; onMouseDown?: (e: React.MouseEvent, id: string) => void; onPreviewGenerated?: (id: string, preview: string) => void; + canManage?: boolean; } const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -76,6 +77,7 @@ export const DrawingCard: React.FC = ({ onDragStart, onMouseDown, onPreviewGenerated, + canManage = true, }) => { const [isRenaming, setIsRenaming] = useState(false); const [showMoveSubmenu, setShowMoveSubmenu] = useState(false); @@ -207,6 +209,7 @@ export const DrawingCard: React.FC = ({ }, []); const handleRenameSubmit = (e: React.FormEvent) => { + if (!canManage) return; e.preventDefault(); if (newName.trim()) { onRename(drawing.id, newName); @@ -313,6 +316,7 @@ export const DrawingCard: React.FC = ({ className="text-sm sm:text-base font-bold text-slate-800 dark:text-neutral-100 truncate cursor-text select-none group-hover:text-neutral-900 dark:group-hover:text-white transition-colors" title={drawing.name} onDoubleClick={(e) => { + if (!canManage) return; e.stopPropagation(); setIsRenaming(true); }} @@ -327,15 +331,17 @@ export const DrawingCard: React.FC = ({

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...
+ {sharingAvailable && ( + <> +
+ + + )} +
@@ -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} + +
+
+ + +
+
+ ))} +
+ )} + +
+ +
+
+
+ )}
); 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: {