test: stabilize e2e auth and rate limits
This commit is contained in:
@@ -108,7 +108,7 @@ jobs:
|
||||
run: |
|
||||
# Start backend server in background
|
||||
cd backend
|
||||
DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:5173" npm run dev &
|
||||
NODE_ENV="test" DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:5173" npm run dev &
|
||||
BACKEND_PID=$!
|
||||
cd ..
|
||||
|
||||
@@ -150,7 +150,11 @@ jobs:
|
||||
|
||||
exit $TEST_EXIT_CODE
|
||||
env:
|
||||
AUTH_USERNAME: admin
|
||||
AUTH_PASSWORD: admin123
|
||||
DATABASE_URL: file:${{ github.workspace }}/backend/prisma/e2e-test.db
|
||||
CSRF_MAX_REQUESTS: "10000"
|
||||
RATE_LIMIT_MAX_REQUESTS: "20000"
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -9,3 +9,5 @@ dist
|
||||
*.log
|
||||
prisma/dev.db
|
||||
prisma/dev.db-journal
|
||||
prisma/*.db
|
||||
prisma/*.db-*
|
||||
|
||||
@@ -25,6 +25,15 @@ if [ -f "/app/prisma/dev.db" ]; then
|
||||
chmod 666 /app/prisma/dev.db
|
||||
fi
|
||||
|
||||
# Optionally reset the database (used for E2E runs)
|
||||
if [ "${RESET_DB_ON_START}" = "true" ]; then
|
||||
DB_PATH="${DATABASE_URL#file:}"
|
||||
if [ "$DB_PATH" != "$DATABASE_URL" ]; then
|
||||
echo "Resetting database at ${DB_PATH}..."
|
||||
rm -f "${DB_PATH}" "${DB_PATH}-journal" "${DB_PATH}-wal" "${DB_PATH}-shm"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. Run Migrations (Drop privileges to nodejs)
|
||||
echo "Running database migrations..."
|
||||
su-exec nodejs npx prisma migrate deploy
|
||||
|
||||
@@ -20,8 +20,8 @@ describe("Authentication flows", () => {
|
||||
setupTestDb();
|
||||
prisma = getTestPrisma();
|
||||
await initTestDb(prisma);
|
||||
const appModule = await import("../index");
|
||||
app = appModule.default || appModule.app || appModule;
|
||||
const appModule = (await import("../index")) as { default: unknown };
|
||||
app = appModule.default;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -51,7 +51,9 @@ describe("Authentication flows", () => {
|
||||
.set("x-csrf-token", token)
|
||||
.send({ username: "admin", password: "password123" });
|
||||
|
||||
return login.headers["set-cookie"] as string[] | undefined;
|
||||
const cookies = login.headers["set-cookie"];
|
||||
if (!cookies) return undefined;
|
||||
return Array.isArray(cookies) ? cookies : [cookies];
|
||||
};
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
+55
-1
@@ -804,6 +804,55 @@ app.post("/auth/password", async (req, res) => {
|
||||
return res.json(authChangePasswordResponse(updated));
|
||||
});
|
||||
|
||||
app.post("/auth/test/must-reset", async (req, res) => {
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
return res.status(404).json({
|
||||
error: "Not found",
|
||||
message: "Endpoint is only available in test environments.",
|
||||
});
|
||||
}
|
||||
|
||||
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
|
||||
if (!session) {
|
||||
return res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
message: "Authentication required",
|
||||
});
|
||||
}
|
||||
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
|
||||
if (!currentUser || currentUser.role !== "ADMIN") {
|
||||
return res.status(403).json({
|
||||
error: "Forbidden",
|
||||
message: "Admin privileges required.",
|
||||
});
|
||||
}
|
||||
|
||||
const payloadSchema = z.object({ enabled: z.boolean() });
|
||||
const parsed = payloadSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid payload",
|
||||
message: "Expected { enabled: boolean }.",
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: currentUser.id },
|
||||
data: { mustResetPassword: parsed.data.enabled },
|
||||
select: { id: true, username: true, email: true, role: true, mustResetPassword: true },
|
||||
});
|
||||
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
return res.json({
|
||||
user: toAuthUserWithResetFlag(updated as AuthenticatedUser & { mustResetPassword: boolean }),
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/auth/register", async (req, res) => {
|
||||
const config = await getSystemConfig();
|
||||
const existingUsers = await prisma.user.count();
|
||||
@@ -1930,11 +1979,16 @@ const ensureTrashCollection = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const shouldEnsureInitialAdmin =
|
||||
process.env.NODE_ENV !== "test" && process.env.SKIP_INITIAL_ADMIN !== "true";
|
||||
|
||||
httpServer.listen(PORT, async () => {
|
||||
await initializeUploadDir();
|
||||
await ensureTrashCollection();
|
||||
await ensureSystemConfig();
|
||||
await ensureInitialAdminUser();
|
||||
if (shouldEnsureInitialAdmin) {
|
||||
await ensureInitialAdminUser();
|
||||
}
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
- DATABASE_URL=file:/app/prisma/e2e-test.db
|
||||
- PORT=8000
|
||||
- NODE_ENV=test
|
||||
- RESET_DB_ON_START=true
|
||||
# Include both with and without :80 because browsers omit default ports in Origin.
|
||||
- FRONTEND_URL=http://frontend,http://frontend:80,http://localhost:5173
|
||||
ports:
|
||||
@@ -70,7 +71,7 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- BASE_URL=http://frontend:80
|
||||
- API_URL=http://backend:8000
|
||||
- API_URL=http://frontend:80/api
|
||||
- NO_SERVER=true
|
||||
- CI=true
|
||||
volumes:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
import {
|
||||
@@ -18,6 +19,21 @@ import {
|
||||
* - Drag multiple selected drawings
|
||||
*/
|
||||
|
||||
const waitForDrawingImports = async (page: Page, count: number) => {
|
||||
const waiters = Array.from({ length: count }, () =>
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/drawings") &&
|
||||
response.request().method() === "POST"
|
||||
)
|
||||
);
|
||||
|
||||
const responses = await Promise.all(waiters);
|
||||
for (const response of responses) {
|
||||
expect(response.ok()).toBeTruthy();
|
||||
}
|
||||
};
|
||||
|
||||
test.describe("Drag and Drop - Collections", () => {
|
||||
let createdDrawingIds: string[] = [];
|
||||
let createdCollectionIds: string[] = [];
|
||||
@@ -272,17 +288,15 @@ test.describe("Drag and Drop - File Import", () => {
|
||||
|
||||
// Find the hidden file input and upload the file
|
||||
const fileInput = page.locator("#dashboard-import");
|
||||
const importResponses = waitForDrawingImports(page, 1);
|
||||
await fileInput.setInputFiles(fixturePath);
|
||||
|
||||
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
|
||||
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
|
||||
await importResponses;
|
||||
|
||||
// Search for the imported drawing (it uses the filename as name)
|
||||
await page.getByPlaceholder("Search drawings...").fill("small-image");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify at least one drawing was imported
|
||||
const importedCards = page.locator("[id^='drawing-card-']");
|
||||
await expect(importedCards.first()).toBeVisible();
|
||||
await expect(importedCards.first()).toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -388,9 +388,16 @@ test.describe("Drawing Deletion", () => {
|
||||
// Card should be gone
|
||||
await expect(card).not.toBeVisible();
|
||||
|
||||
// Verify via API that drawing is deleted
|
||||
const response = await request.get(`${API_URL}/drawings/${drawing.id}`);
|
||||
expect(response.status()).toBe(404);
|
||||
// Verify via API that drawing is deleted (allow a short window for backend completion)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const response = await request.get(`${API_URL}/drawings/${drawing.id}`);
|
||||
return response.status();
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.toBe(404);
|
||||
|
||||
// Remove from cleanup list since it's already deleted
|
||||
createdDrawingIds = createdDrawingIds.filter(id => id !== drawing.id);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import {
|
||||
API_URL,
|
||||
createDrawing,
|
||||
@@ -20,6 +21,21 @@ import {
|
||||
* - Import database backup
|
||||
*/
|
||||
|
||||
const waitForDrawingImports = async (page: Page, count: number) => {
|
||||
const waiters = Array.from({ length: count }, () =>
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/drawings") &&
|
||||
response.request().method() === "POST"
|
||||
)
|
||||
);
|
||||
|
||||
const responses = await Promise.all(waiters);
|
||||
for (const response of responses) {
|
||||
expect(response.ok()).toBeTruthy();
|
||||
}
|
||||
};
|
||||
|
||||
test.describe("Export Functionality", () => {
|
||||
let createdDrawingIds: string[] = [];
|
||||
let createdCollectionIds: string[] = [];
|
||||
@@ -214,24 +230,18 @@ test.describe.serial("Import Functionality", () => {
|
||||
const fileInput = page.locator("#dashboard-import");
|
||||
|
||||
// Create a buffer from the fixture content
|
||||
const importResponses = waitForDrawingImports(page, 1);
|
||||
await fileInput.setInputFiles({
|
||||
name: `Import_ExcalidrawTest_${Date.now()}.excalidraw`,
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(fixtureContent),
|
||||
});
|
||||
|
||||
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
|
||||
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Reload to ensure dashboard state reflects the newly imported drawing
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await importResponses;
|
||||
|
||||
// Verify the drawing was imported - the drawing name is the filename without extension
|
||||
await page.getByPlaceholder("Search drawings...").fill("Import_ExcalidrawTest");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const importedCards = page.locator("[id^='drawing-card-']");
|
||||
await expect(importedCards.first()).toBeVisible({ timeout: 10000 });
|
||||
await expect(importedCards.first()).toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
|
||||
test("should import JSON drawing file from Dashboard", async ({ page }) => {
|
||||
@@ -281,31 +291,18 @@ test.describe.serial("Import Functionality", () => {
|
||||
|
||||
const fileInput = page.locator("#dashboard-import");
|
||||
|
||||
const importResponses = waitForDrawingImports(page, 1);
|
||||
await fileInput.setInputFiles({
|
||||
name: `${testName}.json`,
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(jsonContent),
|
||||
});
|
||||
|
||||
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
|
||||
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Check if upload failed (shows "Failed" text in the upload status)
|
||||
const failedIndicator = page.getByText("Failed");
|
||||
if (await failedIndicator.isVisible()) {
|
||||
console.log("Import failed - skipping rest of test");
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload to force a fresh fetch of drawings after import
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await importResponses;
|
||||
|
||||
// Clear any existing search and search for the imported drawing
|
||||
const searchInput = page.getByPlaceholder("Search drawings...");
|
||||
await searchInput.clear();
|
||||
await searchInput.fill(testName);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Wait for the card to appear - the drawing should be visible in the UI
|
||||
const importedCards = page.locator("[id^='drawing-card-']");
|
||||
await expect(importedCards.first()).toBeVisible({ timeout: 15000 });
|
||||
@@ -326,10 +323,8 @@ test.describe.serial("Import Functionality", () => {
|
||||
buffer: Buffer.from(invalidContent),
|
||||
});
|
||||
|
||||
// Wait for upload to complete and check for failure indicator
|
||||
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
|
||||
// Should show "Failed" status in the upload status component
|
||||
await expect(page.getByText("Failed")).toBeVisible();
|
||||
// Should show error modal for invalid file
|
||||
await expect(page.getByText("Import Failed")).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("should import multiple drawings at once", async ({ page }) => {
|
||||
@@ -364,17 +359,15 @@ test.describe.serial("Import Functionality", () => {
|
||||
];
|
||||
|
||||
const fileInput = page.locator("#dashboard-import");
|
||||
const importResponses = waitForDrawingImports(page, files.length);
|
||||
await fileInput.setInputFiles(files);
|
||||
|
||||
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
|
||||
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
|
||||
await importResponses;
|
||||
|
||||
// Verify both were imported by searching for the unique prefix
|
||||
await page.getByPlaceholder("Search drawings...").fill(searchPrefix);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const importedCards = page.locator("[id^='drawing-card-']");
|
||||
await expect(importedCards).toHaveCount(2);
|
||||
await expect(importedCards).toHaveCount(2, { timeout: 30000 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { PrismaClient } from "../../backend/src/generated/client";
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || "http://localhost:5173";
|
||||
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
|
||||
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin123";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
|
||||
const ensureLoggedOut = async (page: Page) => {
|
||||
await page.context().clearCookies();
|
||||
@@ -57,33 +56,62 @@ const ensureDashboard = async (page: Page) => {
|
||||
await expect(page.getByPlaceholder("Search drawings...")).toBeVisible({ timeout: 30000 });
|
||||
};
|
||||
|
||||
const setMustResetPassword = async (enabled: boolean) => {
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL is not set for e2e test.");
|
||||
}
|
||||
type CsrfInfo = {
|
||||
token: string;
|
||||
headerName: string;
|
||||
};
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
db: { url: DATABASE_URL },
|
||||
},
|
||||
const fetchCsrfInfo = async (page: Page): Promise<CsrfInfo> => {
|
||||
const response = await page.request.get(`${BASE_URL}/api/csrf-token`, {
|
||||
headers: { origin: BASE_URL },
|
||||
});
|
||||
|
||||
try {
|
||||
const admin = await prisma.user.findFirst({
|
||||
where: { username: AUTH_USERNAME },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!response.ok()) {
|
||||
const text = await response.text();
|
||||
throw new Error(
|
||||
`Failed to fetch CSRF token: ${response.status()} ${text || "(empty response)"}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!admin) {
|
||||
throw new Error(`Admin user ${AUTH_USERNAME} not found.`);
|
||||
}
|
||||
const data = (await response.json()) as { token: string; header?: string };
|
||||
if (!data || typeof data.token !== "string" || data.token.trim().length === 0) {
|
||||
throw new Error("Failed to fetch CSRF token: missing token in response");
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: admin.id },
|
||||
data: { mustResetPassword: enabled },
|
||||
const headerName =
|
||||
typeof data.header === "string" && data.header.trim().length > 0
|
||||
? data.header
|
||||
: "x-csrf-token";
|
||||
|
||||
return { token: data.token, headerName };
|
||||
};
|
||||
|
||||
const setMustResetPassword = async (page: Page, enabled: boolean) => {
|
||||
const csrfInfo = await fetchCsrfInfo(page);
|
||||
let response = await page.request.post(`${BASE_URL}/api/auth/test/must-reset`, {
|
||||
headers: {
|
||||
origin: BASE_URL,
|
||||
"Content-Type": "application/json",
|
||||
[csrfInfo.headerName]: csrfInfo.token,
|
||||
},
|
||||
data: { enabled },
|
||||
});
|
||||
|
||||
if (!response.ok() && response.status() === 403) {
|
||||
const refreshed = await fetchCsrfInfo(page);
|
||||
response = await page.request.post(`${BASE_URL}/api/auth/test/must-reset`, {
|
||||
headers: {
|
||||
origin: BASE_URL,
|
||||
"Content-Type": "application/json",
|
||||
[refreshed.headerName]: refreshed.token,
|
||||
},
|
||||
data: { enabled },
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
if (!response.ok()) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to toggle mustResetPassword: ${response.status()} ${text}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,7 +135,7 @@ test.describe("Admin password reset", () => {
|
||||
await expect(page.getByPlaceholder("Search drawings...")).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
await setMustResetPassword(true);
|
||||
await setMustResetPassword(page, true);
|
||||
await ensureLoggedOut(page);
|
||||
|
||||
await login(page, AUTH_PASSWORD);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import { importDrawings } from '../utils/importUtils';
|
||||
import { importDrawings, type ImportResult } from '../utils/importUtils';
|
||||
|
||||
export type UploadStatus = 'pending' | 'uploading' | 'processing' | 'success' | 'error';
|
||||
|
||||
@@ -11,9 +11,35 @@ export interface UploadTask {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const generateUploadId = (): string => {
|
||||
const cryptoObj: Crypto | undefined =
|
||||
typeof globalThis !== "undefined"
|
||||
? globalThis.crypto || (globalThis as any).msCrypto
|
||||
: undefined;
|
||||
|
||||
if (cryptoObj?.randomUUID) {
|
||||
return cryptoObj.randomUUID();
|
||||
}
|
||||
|
||||
if (cryptoObj?.getRandomValues) {
|
||||
const bytes = new Uint8Array(16);
|
||||
cryptoObj.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // RFC 4122 version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
|
||||
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
|
||||
.slice(6, 8)
|
||||
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
|
||||
}
|
||||
|
||||
return `upload-${Date.now().toString(16)}-${Math.random()
|
||||
.toString(16)
|
||||
.slice(2)}`;
|
||||
};
|
||||
|
||||
interface UploadContextType {
|
||||
tasks: UploadTask[];
|
||||
uploadFiles: (files: File[], targetCollectionId: string | null) => Promise<void>;
|
||||
uploadFiles: (files: File[], targetCollectionId: string | null) => Promise<ImportResult>;
|
||||
clearCompleted: () => void;
|
||||
removeTask: (id: string) => void;
|
||||
isUploading: boolean;
|
||||
@@ -48,7 +74,7 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
|
||||
const uploadFiles = useCallback(async (files: File[], targetCollectionId: string | null) => {
|
||||
const newTasks: UploadTask[] = files.map(f => ({
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUploadId(),
|
||||
fileName: f.name,
|
||||
status: 'pending',
|
||||
progress: 0
|
||||
@@ -68,13 +94,18 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
};
|
||||
|
||||
try {
|
||||
await importDrawings(files, targetCollectionId, undefined, handleProgress);
|
||||
return await importDrawings(files, targetCollectionId, undefined, handleProgress);
|
||||
} catch (e) {
|
||||
console.error("Global upload error", e);
|
||||
// Mark all new tasks as error if something crashed completely
|
||||
newTasks.forEach(t => {
|
||||
updateTask(t.id, { status: 'error', error: 'Upload failed unexpectedly' });
|
||||
});
|
||||
return {
|
||||
success: 0,
|
||||
failed: newTasks.length,
|
||||
errors: ['Upload failed unexpectedly'],
|
||||
};
|
||||
}
|
||||
}, [updateTask]);
|
||||
|
||||
|
||||
@@ -306,10 +306,20 @@ export const Dashboard: React.FC = () => {
|
||||
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
|
||||
|
||||
// Use the global upload context
|
||||
uploadFiles(fileArray, targetCollectionId).finally(() => {
|
||||
try {
|
||||
const result = await uploadFiles(fileArray, targetCollectionId);
|
||||
if (result.failed > 0) {
|
||||
setShowImportError({
|
||||
isOpen: true,
|
||||
message: result.errors.length > 0
|
||||
? result.errors.join("\n")
|
||||
: "Some files failed to import.",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
// Refresh after all uploads complete (success or failure)
|
||||
refreshData();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameDrawing = async (id: string, name: string) => {
|
||||
@@ -532,9 +542,19 @@ export const Dashboard: React.FC = () => {
|
||||
|
||||
const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib'));
|
||||
if (drawingFiles.length > 0) {
|
||||
uploadFiles(drawingFiles, targetCollectionId).finally(() => {
|
||||
try {
|
||||
const result = await uploadFiles(drawingFiles, targetCollectionId);
|
||||
if (result.failed > 0) {
|
||||
setShowImportError({
|
||||
isOpen: true,
|
||||
message: result.errors.length > 0
|
||||
? result.errors.join("\n")
|
||||
: "Some files failed to import.",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
refreshData();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,12 @@ import { exportToSvg } from "@excalidraw/excalidraw";
|
||||
import { api } from "../api";
|
||||
import { type UploadStatus } from "../context/UploadContext";
|
||||
|
||||
export type ImportResult = {
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export const importDrawings = async (
|
||||
files: File[],
|
||||
targetCollectionId: string | null,
|
||||
|
||||
Reference in New Issue
Block a user