feat(collab): restore cross-account sharing and reliable realtime sync

This commit is contained in:
2026-02-13 19:02:03 +01:00
parent 12da89b815
commit 75cbe97bc0
18 changed files with 1242 additions and 167 deletions
@@ -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");
+35
View File
@@ -27,6 +27,7 @@ model User {
passwordResetTokens PasswordResetToken[] passwordResetTokens PasswordResetToken[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
auditLogs AuditLog[] auditLogs AuditLog[]
drawingShareGrants DrawingShareGrant[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -67,6 +68,8 @@ model Drawing {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
collectionId String? collectionId String?
collection Collection? @relation(fields: [collectionId], references: [id]) collection Collection? @relation(fields: [collectionId], references: [id])
shareLinks DrawingShareLink[]
shareGrants DrawingShareGrant[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -74,6 +77,38 @@ model Drawing {
@@index([userId, collectionId, updatedAt]) @@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 { model Library {
id String @id // User-specific library ID (e.g., "user_<userId>") id String @id // User-specific library ID (e.g., "user_<userId>")
items String @default("[]") // Stored as JSON string array of library items items String @default("[]") // Stored as JSON string array of library items
+2 -1
View File
@@ -282,7 +282,7 @@ app.use(
cors({ cors({
origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)), origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)),
credentials: true, 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"], exposedHeaders: ["x-csrf-token", "x-request-id"],
}) })
); );
@@ -574,6 +574,7 @@ apiApp.get("/health", (req, res) => {
registerDashboardRoutes(apiApp, { registerDashboardRoutes(apiApp, {
prisma, prisma,
authModeService,
requireAuth, requireAuth,
asyncHandler, asyncHandler,
parseJsonField, parseJsonField,
+302 -25
View File
@@ -7,6 +7,13 @@ import {
toInternalTrashCollectionId, toInternalTrashCollectionId,
toPublicTrashCollectionId, toPublicTrashCollectionId,
} from "./trash"; } from "./trash";
import {
ensureShareLinkForRole,
isAtLeastRole,
isShareRole,
rotateShareLinkForRole,
resolveDrawingAccess,
} from "../../server/drawingAccess";
const getRouteIdParam = (value: string | string[] | undefined): string | null => { const getRouteIdParam = (value: string | string[] | undefined): string | null => {
if (typeof value === "string" && value.trim().length > 0) return value; if (typeof value === "string" && value.trim().length > 0) return value;
@@ -16,12 +23,29 @@ const getRouteIdParam = (value: string | string[] | undefined): string | null =>
return 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<string, unknown>): boolean => {
const allowed = new Set(["elements", "appState", "files", "preview", "version"]);
return Object.keys(payload).every((key) => allowed.has(key));
};
export const registerDrawingRoutes = ( export const registerDrawingRoutes = (
app: express.Express, app: express.Express,
deps: DashboardRouteDeps deps: DashboardRouteDeps
) => { ) => {
const { const {
prisma, prisma,
authModeService,
requireAuth, requireAuth,
asyncHandler, asyncHandler,
parseJsonField, parseJsonField,
@@ -152,6 +176,7 @@ export const registerDrawingRoutes = (
if (shouldIncludeData) { if (shouldIncludeData) {
responsePayload = (drawings as any[]).map((d: any) => ({ responsePayload = (drawings as any[]).map((d: any) => ({
...d, ...d,
accessRole: "owner",
collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id), collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id),
elements: parseJsonField(d.elements, []), elements: parseJsonField(d.elements, []),
appState: parseJsonField(d.appState, {}), appState: parseJsonField(d.appState, {}),
@@ -160,6 +185,7 @@ export const registerDrawingRoutes = (
} else { } else {
responsePayload = (drawings as any[]).map((d: any) => ({ responsePayload = (drawings as any[]).map((d: any) => ({
...d, ...d,
accessRole: "owner",
collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id), collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id),
})); }));
} }
@@ -177,27 +203,246 @@ export const registerDrawingRoutes = (
return res.send(body); 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<Array<{
id: string;
name: string;
collectionId: string | null;
preview: string | null;
version: number;
createdAt: string;
updatedAt: string;
elements: string;
appState: string;
files: string;
ownerId: string;
ownerName: string;
ownerEmail: string;
roleRank: number;
}>>(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<string, unknown>;
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) => { app.get("/drawings/:id", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" }); if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const id = getRouteIdParam(req.params.id); const id = getRouteIdParam(req.params.id);
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
const drawing = await prisma.drawing.findFirst({
where: { const access = await resolveDrawingAccess({
id, prisma,
drawingId: id,
userId: req.user.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" }); 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({ return res.json({
...drawing, ...access.drawing,
collectionId: toPublicTrashCollectionId(drawing.collectionId, req.user.id), accessRole: access.role,
elements: parseJsonField(drawing.elements, []), collectionId:
appState: parseJsonField(drawing.appState, {}), access.role === "owner"
files: parseJsonField(drawing.files, {}), ? 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({ return res.json({
...newDrawing, ...newDrawing,
accessRole: "owner",
collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id), collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id),
elements: parseJsonField(newDrawing.elements, []), elements: parseJsonField(newDrawing.elements, []),
appState: parseJsonField(newDrawing.appState, {}), appState: parseJsonField(newDrawing.appState, {}),
@@ -266,10 +512,13 @@ export const registerDrawingRoutes = (
const id = getRouteIdParam(req.params.id); const id = getRouteIdParam(req.params.id);
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); 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); const parsed = drawingUpdateSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
@@ -288,6 +537,17 @@ export const registerDrawingRoutes = (
files?: Record<string, unknown>; files?: Record<string, unknown>;
version?: number; version?: number;
}; };
const payloadRecord = payload as unknown as Record<string, unknown>;
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 trashCollectionId = getUserTrashCollectionId(req.user.id);
const isSceneUpdate = const isSceneUpdate =
payload.elements !== undefined || payload.elements !== undefined ||
@@ -304,6 +564,9 @@ export const registerDrawingRoutes = (
if (payload.preview !== undefined) data.preview = payload.preview; if (payload.preview !== undefined) data.preview = payload.preview;
if (payload.collectionId !== undefined) { 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") { if (payload.collectionId === "trash") {
await ensureTrashCollection(prisma, req.user.id); await ensureTrashCollection(prisma, req.user.id);
(data as Prisma.DrawingUncheckedUpdateInput).collectionId = trashCollectionId; (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) { if (isSceneUpdate && payload.version !== undefined) {
updateWhere.version = payload.version; updateWhere.version = payload.version;
} }
@@ -330,7 +596,7 @@ export const registerDrawingRoutes = (
if (updateResult.count === 0) { if (updateResult.count === 0) {
if (isSceneUpdate && payload.version !== undefined) { if (isSceneUpdate && payload.version !== undefined) {
const latestDrawing = await prisma.drawing.findFirst({ const latestDrawing = await prisma.drawing.findFirst({
where: { id, userId: req.user.id }, where: { id },
select: { version: true }, select: { version: true },
}); });
return res.status(409).json({ return res.status(409).json({
@@ -344,7 +610,7 @@ export const registerDrawingRoutes = (
} }
const updatedDrawing = await prisma.drawing.findFirst({ const updatedDrawing = await prisma.drawing.findFirst({
where: { id, userId: req.user.id }, where: { id },
}); });
if (!updatedDrawing) { if (!updatedDrawing) {
return res.status(404).json({ error: "Drawing not found" }); return res.status(404).json({ error: "Drawing not found" });
@@ -353,7 +619,11 @@ export const registerDrawingRoutes = (
return res.json({ return res.json({
...updatedDrawing, ...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, []), elements: parseJsonField(updatedDrawing.elements, []),
appState: parseJsonField(updatedDrawing.appState, {}), appState: parseJsonField(updatedDrawing.appState, {}),
files: parseJsonField(updatedDrawing.files, {}), files: parseJsonField(updatedDrawing.files, {}),
@@ -395,20 +665,26 @@ export const registerDrawingRoutes = (
const id = getRouteIdParam(req.params.id); const id = getRouteIdParam(req.params.id);
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); 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" }); const access = await resolveDrawingAccess({
let duplicatedCollectionId = original.collectionId; prisma,
if (isTrashCollectionId(original.collectionId, req.user.id)) { 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); await ensureTrashCollection(prisma, req.user.id);
duplicatedCollectionId = getUserTrashCollectionId(req.user.id); duplicatedCollectionId = getUserTrashCollectionId(req.user.id);
} }
const newDrawing = await prisma.drawing.create({ const newDrawing = await prisma.drawing.create({
data: { data: {
name: `${original.name} (Copy)`, name: `${access.drawing.name} (Copy)`,
elements: original.elements, elements: access.drawing.elements,
appState: original.appState, appState: access.drawing.appState,
files: original.files, files: access.drawing.files,
userId: req.user.id, userId: req.user.id,
collectionId: duplicatedCollectionId, collectionId: duplicatedCollectionId,
version: 1, version: 1,
@@ -418,6 +694,7 @@ export const registerDrawingRoutes = (
return res.json({ return res.json({
...newDrawing, ...newDrawing,
accessRole: "owner",
collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id), collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id),
elements: parseJsonField(newDrawing.elements, []), elements: parseJsonField(newDrawing.elements, []),
appState: parseJsonField(newDrawing.appState, {}), appState: parseJsonField(newDrawing.appState, {}),
+2
View File
@@ -1,6 +1,7 @@
import express from "express"; import express from "express";
import { z } from "zod"; import { z } from "zod";
import { Prisma, PrismaClient } from "../../generated/client"; import { Prisma, PrismaClient } from "../../generated/client";
import { AuthModeService } from "../../auth/authMode";
export type SortField = "name" | "createdAt" | "updatedAt"; export type SortField = "name" | "createdAt" | "updatedAt";
export type SortDirection = "asc" | "desc"; export type SortDirection = "asc" | "desc";
@@ -30,6 +31,7 @@ type LogAuditEvent = (params: {
export type DashboardRouteDeps = { export type DashboardRouteDeps = {
prisma: PrismaClient; prisma: PrismaClient;
authModeService: AuthModeService;
requireAuth: express.RequestHandler; requireAuth: express.RequestHandler;
asyncHandler: <T = void>( asyncHandler: <T = void>(
fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise<T> fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise<T>
+244
View File
@@ -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<string>(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<void> => {
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<DrawingAccessResult | null> => {
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<RawShareLink[]>`
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<RawShareGrant[]>`
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<Array<{ id: string; token: string }>>`
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<Array<{ id: string }>>`
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<Map<string, ShareRole>> => {
const roleByDrawingId = new Map<string, ShareRole>();
if (drawingIds.length === 0) return roleByDrawingId;
const idsSql = Prisma.join(drawingIds);
const rows = await prisma.$queryRaw<Array<{ drawingId: string; role: string }>>(
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;
};
+15 -8
View File
@@ -3,6 +3,7 @@ import { Server } from "socket.io";
import { PrismaClient } from "../generated/client"; import { PrismaClient } from "../generated/client";
import { AuthModeService } from "../auth/authMode"; import { AuthModeService } from "../auth/authMode";
import { ACCESS_TOKEN_COOKIE_NAME, parseCookieHeader } from "../auth/cookies"; import { ACCESS_TOKEN_COOKIE_NAME, parseCookieHeader } from "../auth/cookies";
import { isAtLeastRole, resolveDrawingAccess } from "./drawingAccess";
interface User { interface User {
id: string; id: string;
@@ -110,7 +111,7 @@ export const registerSocketHandlers = ({
io.on("connection", (socket) => { io.on("connection", (socket) => {
const authenticatedUserId = socketUserMap.get(socket.id); const authenticatedUserId = socketUserMap.get(socket.id);
const authorizedDrawingIds = new Set<string>(); const authorizedDrawingRoles = new Map<string, "owner" | "editor" | "viewer">();
socket.on( socket.on(
"join-room", "join-room",
@@ -123,20 +124,22 @@ export const registerSocketHandlers = ({
}) => { }) => {
try { try {
if (authenticatedUserId) { if (authenticatedUserId) {
const drawing = await prisma.drawing.findFirst({ const drawing = await resolveDrawingAccess({
where: { id: drawingId, userId: authenticatedUserId }, prisma,
select: { id: true }, drawingId,
userId: authenticatedUserId,
}); });
if (!drawing) { if (!drawing) {
socket.emit("error", { message: "You do not have access to this drawing" }); socket.emit("error", { message: "You do not have access to this drawing" });
return; return;
} }
authorizedDrawingRoles.set(drawingId, drawing.role);
} }
const roomId = `drawing_${drawingId}`; const roomId = `drawing_${drawingId}`;
socket.join(roomId); socket.join(roomId);
authorizedDrawingIds.add(drawingId);
let trustedUserId = let trustedUserId =
typeof user?.id === "string" && user.id.trim().length > 0 typeof user?.id === "string" && user.id.trim().length > 0
@@ -179,7 +182,7 @@ export const registerSocketHandlers = ({
socket.on("cursor-move", (data) => { socket.on("cursor-move", (data) => {
const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null; const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null;
if (!drawingId || !authorizedDrawingIds.has(drawingId)) { if (!drawingId || !authorizedDrawingRoles.has(drawingId)) {
return; return;
} }
const roomId = `drawing_${drawingId}`; const roomId = `drawing_${drawingId}`;
@@ -188,7 +191,11 @@ export const registerSocketHandlers = ({
socket.on("element-update", (data) => { socket.on("element-update", (data) => {
const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null; 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; return;
} }
const roomId = `drawing_${drawingId}`; const roomId = `drawing_${drawingId}`;
@@ -198,7 +205,7 @@ export const registerSocketHandlers = ({
socket.on( socket.on(
"user-activity", "user-activity",
({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => { ({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => {
if (!authorizedDrawingIds.has(drawingId)) { if (!authorizedDrawingRoles.has(drawingId)) {
return; return;
} }
const roomId = `drawing_${drawingId}`; const roomId = `drawing_${drawingId}`;
+8
View File
@@ -52,6 +52,14 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/shared"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route <Route
path="/settings" path="/settings"
element={ element={
+62 -3
View File
@@ -2,7 +2,10 @@ import axios from "axios";
import type { Drawing, Collection, DrawingSummary } from "../types"; import type { Drawing, Collection, DrawingSummary } from "../types";
import { normalizePreviewSvg } from "../utils/previewSvg"; import { normalizePreviewSvg } from "../utils/previewSvg";
export const API_URL = import.meta.env.VITE_API_URL || "/api"; const DEFAULT_DEV_API_URL = "http://localhost:8000/api";
export const API_URL =
import.meta.env.VITE_API_URL ||
(import.meta.env.DEV ? DEFAULT_DEV_API_URL : "/api");
export const api = axios.create({ export const api = axios.create({
baseURL: API_URL, baseURL: API_URL,
@@ -416,6 +419,7 @@ export interface PaginatedDrawings<T> {
export type DrawingSortField = "name" | "createdAt" | "updatedAt"; export type DrawingSortField = "name" | "createdAt" | "updatedAt";
export type SortDirection = "asc" | "desc"; export type SortDirection = "asc" | "desc";
export type ShareLinkRole = "viewer" | "editor";
export function getDrawings( export function getDrawings(
search?: string, search?: string,
@@ -475,8 +479,46 @@ export async function getDrawings(
}; };
} }
export const getDrawing = async (id: string) => { export const getSharedDrawings = async (options?: {
const response = await api.get<Drawing>(`/drawings/${id}`); search?: string;
includeData?: boolean;
limit?: number;
offset?: number;
sortField?: DrawingSortField;
sortDirection?: SortDirection;
}) => {
const params: Record<string, string | number> = {};
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<PaginatedDrawings<Drawing>>("/drawings/shared", { params });
return {
...response.data,
drawings: response.data.drawings.map(deserializeDrawing),
};
}
const response = await api.get<PaginatedDrawings<DrawingSummary>>("/drawings/shared", { params });
return {
...response.data,
drawings: response.data.drawings.map(deserializeDrawingSummary),
};
};
export const getDrawing = async (
id: string,
options?: { shareToken?: string }
) => {
const headers: Record<string, string> = {};
if (options?.shareToken) {
headers["x-share-token"] = options.shareToken;
}
const response = await api.get<Drawing>(`/drawings/${id}`, { headers });
return deserializeDrawing(response.data); return deserializeDrawing(response.data);
}; };
@@ -508,6 +550,23 @@ export const duplicateDrawing = async (id: string) => {
return deserializeDrawing(response.data); 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 () => { export const getCollections = async () => {
const response = await api.get<Collection[]>("/collections"); const response = await api.get<Collection[]>("/collections");
return response.data; return response.data;
+14 -1
View File
@@ -56,6 +56,7 @@ interface DrawingCardProps {
onDragStart?: (e: React.DragEvent, id: string) => void; onDragStart?: (e: React.DragEvent, id: string) => void;
onMouseDown?: (e: React.MouseEvent, id: string) => void; onMouseDown?: (e: React.MouseEvent, id: string) => void;
onPreviewGenerated?: (id: string, preview: string) => void; onPreviewGenerated?: (id: string, preview: string) => void;
canManage?: boolean;
} }
const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => { const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -76,6 +77,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
onDragStart, onDragStart,
onMouseDown, onMouseDown,
onPreviewGenerated, onPreviewGenerated,
canManage = true,
}) => { }) => {
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false); const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
@@ -207,6 +209,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
}, []); }, []);
const handleRenameSubmit = (e: React.FormEvent) => { const handleRenameSubmit = (e: React.FormEvent) => {
if (!canManage) return;
e.preventDefault(); e.preventDefault();
if (newName.trim()) { if (newName.trim()) {
onRename(drawing.id, newName); onRename(drawing.id, newName);
@@ -313,6 +316,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
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" 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} title={drawing.name}
onDoubleClick={(e) => { onDoubleClick={(e) => {
if (!canManage) return;
e.stopPropagation(); e.stopPropagation();
setIsRenaming(true); setIsRenaming(true);
}} }}
@@ -327,6 +331,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
</p> </p>
<div className="relative" onClick={e => e.stopPropagation()}> <div className="relative" onClick={e => e.stopPropagation()}>
{canManage && (
<button <button
onClick={() => setShowCollectionDropdown(!showCollectionDropdown)} onClick={() => setShowCollectionDropdown(!showCollectionDropdown)}
data-testid={`collection-picker-${drawing.id}`} data-testid={`collection-picker-${drawing.id}`}
@@ -336,6 +341,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
> >
{drawing.collectionId ? (collections.find(c => c.id === drawing.collectionId)?.name || 'Collection') : 'Unorganized'} {drawing.collectionId ? (collections.find(c => c.id === drawing.collectionId)?.name || 'Collection') : 'Unorganized'}
</button> </button>
)}
{showCollectionDropdown && ( {showCollectionDropdown && (
<> <>
@@ -387,6 +393,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
style={{ top: contextMenu.y, left: contextMenu.x }} style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{canManage && (
<button <button
onClick={() => { onClick={() => {
setIsRenaming(true); setIsRenaming(true);
@@ -396,7 +403,9 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
> >
<PenTool size={14} /> Rename <PenTool size={14} /> Rename
</button> </button>
)}
{canManage && (
<div <div
className="relative group/move" className="relative group/move"
onMouseEnter={() => setShowMoveSubmenu(true)} onMouseEnter={() => setShowMoveSubmenu(true)}
@@ -437,6 +446,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
</div> </div>
)} )}
</div> </div>
)}
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div> <div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
@@ -468,8 +478,9 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
</div> </div>
)} )}
{canManage && (
<>
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div> <div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
<button <button
onClick={() => { onClick={() => {
onDelete(drawing.id); onDelete(drawing.id);
@@ -479,6 +490,8 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
> >
<Trash2 size={14} /> Delete <Trash2 size={14} /> Delete
</button> </button>
</>
)}
</div> </div>
</div> </div>
</ContextMenuPortal> </ContextMenuPortal>
+18 -1
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; 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 type { Collection } from '../types';
import clsx from 'clsx'; import clsx from 'clsx';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
@@ -207,6 +207,23 @@ export const Sidebar: React.FC<SidebarProps> = ({
onClick={() => onSelectCollection(null)} onClick={() => onSelectCollection(null)}
onDrop={onDrop} onDrop={onDrop}
/> />
{authEnabled && (
<div className="pl-3 pr-2">
<button
onClick={() => onSelectCollection('__shared__')}
className={clsx(
"w-full flex items-center gap-3 px-3 py-2.5 text-sm font-bold rounded-lg transition-all duration-200 border-2",
selectedCollectionId === '__shared__'
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 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)] -translate-y-0.5"
: "text-slate-600 dark:text-neutral-400 border-transparent hover:bg-slate-50 dark:hover:bg-neutral-800 hover:border-black dark:hover:border-neutral-700 hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
)}
>
<Users size={18} className={clsx(selectedCollectionId === '__shared__' ? "text-indigo-900 dark:text-neutral-200" : "text-slate-400 dark:text-neutral-500")} />
<span className="min-w-0 flex-1 text-left">Shared with me</span>
</button>
</div>
)}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
+52 -21
View File
@@ -21,6 +21,7 @@ export const Dashboard: React.FC = () => {
const selectedCollectionId = React.useMemo(() => { const selectedCollectionId = React.useMemo(() => {
if (location.pathname === '/') return undefined; if (location.pathname === '/') return undefined;
if (location.pathname === '/shared') return '__shared__';
if (location.pathname === '/collections') { if (location.pathname === '/collections') {
const id = searchParams.get('id'); const id = searchParams.get('id');
if (id === 'unorganized') return null; if (id === 'unorganized') return null;
@@ -32,6 +33,8 @@ export const Dashboard: React.FC = () => {
const setSelectedCollectionId = (id: string | null | undefined) => { const setSelectedCollectionId = (id: string | null | undefined) => {
if (id === undefined) { if (id === undefined) {
navigate('/'); navigate('/');
} else if (id === '__shared__') {
navigate('/shared');
} else if (id === null) { } else if (id === null) {
navigate('/collections?id=unorganized'); navigate('/collections?id=unorganized');
} else { } else {
@@ -39,6 +42,8 @@ export const Dashboard: React.FC = () => {
} }
}; };
const isSharedView = selectedCollectionId === '__shared__';
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -265,8 +270,13 @@ export const Dashboard: React.FC = () => {
const currentSortOption = sortOptions.find(opt => opt.field === sortConfig.field) || sortOptions[0]; const currentSortOption = sortOptions.find(opt => opt.field === sortConfig.field) || sortOptions[0];
const isTrashView = selectedCollectionId === 'trash'; const isTrashView = selectedCollectionId === 'trash';
const canManageDrawing = useCallback(
(id: string) => (drawings.find((d) => d.id === id)?.accessRole ?? 'owner') === 'owner',
[drawings]
);
const handleCreateDrawing = async () => { const handleCreateDrawing = async () => {
if (isTrashView) return; if (isTrashView || isSharedView) return;
try { try {
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId; const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
const { id } = await api.createDrawing('Untitled Drawing', targetCollectionId); const { id } = await api.createDrawing('Untitled Drawing', targetCollectionId);
@@ -277,7 +287,7 @@ export const Dashboard: React.FC = () => {
}; };
const handleImportDrawings = async (files: FileList | null) => { const handleImportDrawings = async (files: FileList | null) => {
if (!files || isTrashView) return; if (!files || isTrashView || isSharedView) return;
const fileArray = Array.from(files); const fileArray = Array.from(files);
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId; const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
@@ -290,6 +300,7 @@ export const Dashboard: React.FC = () => {
}; };
const handleRenameDrawing = async (id: string, name: string) => { const handleRenameDrawing = async (id: string, name: string) => {
if (!canManageDrawing(id)) return;
setDrawings(prev => prev.map(d => d.id === id ? { ...d, name } : d)); setDrawings(prev => prev.map(d => d.id === id ? { ...d, name } : d));
try { try {
await api.updateDrawing(id, { name }); await api.updateDrawing(id, { name });
@@ -300,6 +311,7 @@ export const Dashboard: React.FC = () => {
}; };
const handleDeleteDrawing = async (id: string) => { const handleDeleteDrawing = async (id: string) => {
if (!canManageDrawing(id)) return;
if (isTrashView) { if (isTrashView) {
// Permanent Delete -> Confirm first // Permanent Delete -> Confirm first
setDrawingToDelete(id); setDrawingToDelete(id);
@@ -327,6 +339,7 @@ export const Dashboard: React.FC = () => {
}; };
const executePermanentDelete = async (id: string) => { const executePermanentDelete = async (id: string) => {
if (!canManageDrawing(id)) return;
setDrawings(prev => { setDrawings(prev => {
const next = prev.filter(d => d.id !== id); const next = prev.filter(d => d.id !== id);
if (next.length !== prev.length) { if (next.length !== prev.length) {
@@ -378,6 +391,8 @@ export const Dashboard: React.FC = () => {
const handleBulkDeleteClick = () => { const handleBulkDeleteClick = () => {
if (selectedIds.size === 0) return; if (selectedIds.size === 0) return;
const ownerSelectedCount = Array.from(selectedIds).filter((id) => canManageDrawing(id)).length;
if (ownerSelectedCount === 0) return;
if (isTrashView) { if (isTrashView) {
setShowBulkDeleteConfirm(true); setShowBulkDeleteConfirm(true);
} else { } else {
@@ -387,10 +402,12 @@ export const Dashboard: React.FC = () => {
const executeBulkMoveToTrash = async () => { const executeBulkMoveToTrash = async () => {
const trashId = 'trash'; const trashId = 'trash';
const ids = Array.from(selectedIds); const ids = Array.from(selectedIds).filter((id) => canManageDrawing(id));
if (ids.length === 0) return;
setDrawings(prev => { 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)); setTotalCount(t => t - (prev.length - next.length));
return next; return next;
}); });
@@ -405,9 +422,11 @@ export const Dashboard: React.FC = () => {
}; };
const executeBulkPermanentDelete = async () => { const executeBulkPermanentDelete = async () => {
const ids = Array.from(selectedIds); const ids = Array.from(selectedIds).filter((id) => canManageDrawing(id));
if (ids.length === 0) return;
setDrawings(prev => { 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)); setTotalCount(t => t - (prev.length - next.length));
return next; return next;
}); });
@@ -425,11 +444,13 @@ export const Dashboard: React.FC = () => {
const handleBulkMove = async (collectionId: string | null) => { const handleBulkMove = async (collectionId: string | null) => {
if (selectedIds.size === 0) return; 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 // Optimistic update
setDrawings(prev => { 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; if (selectedCollectionId === undefined) return updated;
const next = updated.filter(d => { const next = updated.filter(d => {
if (selectedCollectionId === null) return d.collectionId === null; if (selectedCollectionId === null) return d.collectionId === null;
@@ -472,6 +493,7 @@ export const Dashboard: React.FC = () => {
}; };
const handleMoveToCollection = async (id: string, collectionId: string | null) => { const handleMoveToCollection = async (id: string, collectionId: string | null) => {
if (!canManageDrawing(id)) return;
setDrawings(prev => { setDrawings(prev => {
const updated = prev.map(d => d.id === id ? { ...d, collectionId } : d); const updated = prev.map(d => d.id === id ? { ...d, collectionId } : d);
const next = updated.filter(d => { const next = updated.filter(d => {
@@ -530,6 +552,7 @@ export const Dashboard: React.FC = () => {
const viewTitle = React.useMemo(() => { const viewTitle = React.useMemo(() => {
if (selectedCollectionId === undefined) return "All Drawings"; if (selectedCollectionId === undefined) return "All Drawings";
if (selectedCollectionId === null) return "Unorganized"; if (selectedCollectionId === null) return "Unorganized";
if (selectedCollectionId === '__shared__') return "Shared with me";
if (selectedCollectionId === 'trash') return "Trash"; if (selectedCollectionId === 'trash') return "Trash";
const collection = collections.find(c => c.id === selectedCollectionId); const collection = collections.find(c => c.id === selectedCollectionId);
return collection ? collection.name : "Collection"; return collection ? collection.name : "Collection";
@@ -537,6 +560,10 @@ export const Dashboard: React.FC = () => {
const hasSelection = selectedIds.size > 0; const hasSelection = selectedIds.size > 0;
const allSelected = sortedDrawings.length > 0 && selectedIds.size === sortedDrawings.length; 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 = () => { const handleSelectAll = () => {
if (allSelected) { if (allSelected) {
@@ -567,7 +594,7 @@ export const Dashboard: React.FC = () => {
} }
const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib')); const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib'));
if (drawingFiles.length > 0) { if (drawingFiles.length > 0 && !isSharedView) {
uploadFiles(drawingFiles, targetCollectionId).finally(() => { uploadFiles(drawingFiles, targetCollectionId).finally(() => {
refreshData(); refreshData();
}); });
@@ -588,6 +615,9 @@ export const Dashboard: React.FC = () => {
idsToMove.add(draggedDrawingId); idsToMove.add(draggedDrawingId);
} }
idsToMove = new Set(Array.from(idsToMove).filter((id) => canManageDrawing(id)));
if (idsToMove.size === 0) return;
// Optimistic Update // Optimistic Update
setDrawings(prev => { setDrawings(prev => {
const updated = prev.map(d => idsToMove.has(d.id) ? { ...d, collectionId: targetCollectionId } : d); const updated = prev.map(d => idsToMove.has(d.id) ? { ...d, collectionId: targetCollectionId } : d);
@@ -805,10 +835,10 @@ export const Dashboard: React.FC = () => {
<button <button
onClick={handleBulkDeleteClick} onClick={handleBulkDeleteClick}
disabled={!hasSelection} disabled={manageableSelectionCount === 0}
className={clsx( className={clsx(
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all", "h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
hasSelection manageableSelectionCount > 0
? "bg-white dark:bg-neutral-800 border-black dark:border-neutral-700 text-rose-600 dark:text-rose-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] 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 hover:bg-rose-50 dark:hover:bg-rose-900/30" ? "bg-white dark:bg-neutral-800 border-black dark:border-neutral-700 text-rose-600 dark:text-rose-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] 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 hover:bg-rose-50 dark:hover:bg-rose-900/30"
: "bg-slate-100 dark:bg-neutral-900 border-slate-300 dark:border-neutral-800 text-slate-300 dark:text-neutral-700 cursor-not-allowed" : "bg-slate-100 dark:bg-neutral-900 border-slate-300 dark:border-neutral-800 text-slate-300 dark:text-neutral-700 cursor-not-allowed"
)} )}
@@ -833,11 +863,11 @@ export const Dashboard: React.FC = () => {
<div className="relative"> <div className="relative">
<button <button
onClick={() => hasSelection && setShowBulkMoveMenu(!showBulkMoveMenu)} onClick={() => manageableSelectionCount > 0 && setShowBulkMoveMenu(!showBulkMoveMenu)}
disabled={!hasSelection} disabled={manageableSelectionCount === 0}
className={clsx( className={clsx(
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all", "h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
hasSelection manageableSelectionCount > 0
? "bg-white dark:bg-neutral-800 border-black dark:border-neutral-700 text-emerald-600 dark:text-emerald-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] 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 hover:bg-emerald-50 dark:hover:bg-emerald-900/30" ? "bg-white dark:bg-neutral-800 border-black dark:border-neutral-700 text-emerald-600 dark:text-emerald-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] 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 hover:bg-emerald-50 dark:hover:bg-emerald-900/30"
: "bg-slate-100 dark:bg-neutral-900 border-slate-300 dark:border-neutral-800 text-slate-300 dark:text-neutral-700 cursor-not-allowed" : "bg-slate-100 dark:bg-neutral-900 border-slate-300 dark:border-neutral-800 text-slate-300 dark:text-neutral-700 cursor-not-allowed"
)} )}
@@ -849,12 +879,12 @@ export const Dashboard: React.FC = () => {
</div> </div>
</button> </button>
{showBulkMoveMenu && hasSelection && ( {showBulkMoveMenu && manageableSelectionCount > 0 && (
<> <>
<div className="fixed inset-0 z-10" onClick={() => setShowBulkMoveMenu(false)} /> <div className="fixed inset-0 z-10" onClick={() => setShowBulkMoveMenu(false)} />
<div className="absolute right-0 top-full mt-2 w-56 bg-white dark:bg-neutral-800 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] z-50 py-1 max-h-64 overflow-y-auto custom-scrollbar animate-in fade-in zoom-in-95 duration-100"> <div className="absolute right-0 top-full mt-2 w-56 bg-white dark:bg-neutral-800 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] z-50 py-1 max-h-64 overflow-y-auto custom-scrollbar animate-in fade-in zoom-in-95 duration-100">
<div className="px-3 py-2 text-[10px] font-bold uppercase text-slate-400 dark:text-neutral-500 tracking-wider border-b border-slate-100 dark:border-neutral-700 mb-1"> <div className="px-3 py-2 text-[10px] font-bold uppercase text-slate-400 dark:text-neutral-500 tracking-wider border-b border-slate-100 dark:border-neutral-700 mb-1">
Move {selectedIds.size} items to... Move {manageableSelectionCount} items to...
</div> </div>
<button <button
onClick={() => handleBulkMove(null)} onClick={() => handleBulkMove(null)}
@@ -891,10 +921,10 @@ export const Dashboard: React.FC = () => {
<button <button
onClick={() => document.getElementById('dashboard-import')?.click()} onClick={() => document.getElementById('dashboard-import')?.click()}
disabled={isTrashView} disabled={isTrashView || isSharedView}
className={clsx( 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", "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-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)]" : "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 = () => {
<button <button
onClick={handleCreateDrawing} onClick={handleCreateDrawing}
disabled={isTrashView} disabled={isTrashView || isSharedView}
className={clsx( 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", "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-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-indigo-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)]" : "bg-indigo-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)]"
)} )}
@@ -1006,8 +1036,9 @@ export const Dashboard: React.FC = () => {
} }
}} }}
onMouseDown={handleCardMouseDown} onMouseDown={handleCardMouseDown}
onDragStart={handleCardDragStart} onDragStart={(drawing.accessRole ?? 'owner') === 'owner' ? handleCardDragStart : undefined}
onPreviewGenerated={handlePreviewGenerated} onPreviewGenerated={handlePreviewGenerated}
canManage={(drawing.accessRole ?? 'owner') === 'owner'}
/> />
)) ))
)} )}
File diff suppressed because it is too large Load Diff
@@ -36,8 +36,17 @@ export const useDashboardData = ({
const requestVersion = ++listRequestVersionRef.current; const requestVersion = ++listRequestVersionRef.current;
setIsLoading(true); setIsLoading(true);
try { try {
const isSharedView = selectedCollectionId === '__shared__';
const [drawingsRes, collectionsData] = await Promise.all([ const [drawingsRes, collectionsData] = await Promise.all([
api.getDrawings(debouncedSearch, selectedCollectionId, { isSharedView
? api.getSharedDrawings({
search: debouncedSearch || undefined,
limit: pageSize,
offset: 0,
sortField,
sortDirection,
})
: api.getDrawings(debouncedSearch, selectedCollectionId, {
limit: pageSize, limit: pageSize,
offset: 0, offset: 0,
sortField, sortField,
@@ -71,7 +80,16 @@ export const useDashboardData = ({
const requestVersion = listRequestVersionRef.current; const requestVersion = listRequestVersionRef.current;
setIsFetchingMore(true); setIsFetchingMore(true);
try { try {
const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, { 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, limit: pageSize,
offset: drawings.length, offset: drawings.length,
sortField, sortField,
+27
View File
@@ -1,8 +1,35 @@
export interface ElementVersionInfo { export interface ElementVersionInfo {
version: number; version: number;
versionNonce: 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[] = []) => { export const haveSameElements = (a: readonly any[] = [], b: readonly any[] = []) => {
if (!a || !b) return false; if (!a || !b) return false;
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
+10
View File
@@ -1,3 +1,11 @@
export type DrawingAccessRole = "owner" | "editor" | "viewer";
export interface DrawingOwner {
id: string;
name: string;
email?: string;
}
export interface DrawingSummary { export interface DrawingSummary {
id: string; id: string;
name: string; name: string;
@@ -6,6 +14,8 @@ export interface DrawingSummary {
createdAt: number; createdAt: number;
version: number; version: number;
preview?: string | null; preview?: string | null;
accessRole?: DrawingAccessRole;
owner?: DrawingOwner;
} }
export interface Drawing extends DrawingSummary { export interface Drawing extends DrawingSummary {
+18
View File
@@ -17,6 +17,14 @@ export const reconcileElements = (
const value = element?.updated; const value = element?.updated;
return typeof value === "number" ? value : Number(value) || 0; return typeof value === "number" ? value : Number(value) || 0;
}; };
const getComparableContent = (element: any): string => {
if (!element || typeof element !== "object") return "";
const copy = { ...element } as Record<string, unknown>;
delete copy.version;
delete copy.versionNonce;
delete copy.updated;
return JSON.stringify(copy);
};
remoteElements.forEach((remoteEl) => { remoteElements.forEach((remoteEl) => {
const localEl = localMap.get(remoteEl.id); const localEl = localMap.get(remoteEl.id);
@@ -51,7 +59,17 @@ export const reconcileElements = (
getVersionNonce(remoteEl) !== getVersionNonce(localEl) getVersionNonce(remoteEl) !== getVersionNonce(localEl)
) { ) {
localMap.set(remoteEl.id, remoteEl); 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()); return Array.from(localMap.values());
+9 -6
View File
@@ -16,22 +16,25 @@ try {
} }
const appVersion = process.env.VITE_APP_VERSION?.trim() || versionFromFile; 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/ // https://vite.dev/config/
export default defineConfig(({ command }) => { 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 = { const processEnvDefines = {
'process.env.IS_PREACT': JSON.stringify("false"), "process.env.IS_PREACT": JSON.stringify("false"),
'process.env.NODE_ENV': JSON.stringify(nodeEnv), "process.env.NODE_ENV": JSON.stringify(nodeEnv),
}; };
return { return {
plugins: [react()], plugins: [react()],
define: { define: {
...processEnvDefines, ...processEnvDefines,
'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion), "import.meta.env.VITE_APP_VERSION": JSON.stringify(appVersion),
'import.meta.env.VITE_APP_BUILD_LABEL': JSON.stringify(buildLabel), "import.meta.env.VITE_APP_BUILD_LABEL": JSON.stringify(buildLabel),
}, },
optimizeDeps: { optimizeDeps: {
esbuildOptions: { esbuildOptions: {